mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-20 17:00:54 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
d546568a11 | ||
|
|
57a9d8c717 | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: PC host libraries
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "mac/frame-mac-view/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Native libraries
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-${{ matrix.os }}
|
||||
path: desktop/bundle
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py
|
||||
FRAME_PC_REQUIRE_NATIVE=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-windows
|
||||
path: desktop/bundle
|
||||
@@ -3,14 +3,14 @@ name: release
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# native PC helpers, macOS helper, or this workflow) build the same installers as
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -69,62 +69,18 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
script: dist:linux
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
- os: ubuntu-24.04-arm
|
||||
script: dist:linux:arm64
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Linux streaming libraries
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
arch=x64; if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python3 desktop/build.py --out "app/build/desktop/linux-$arch"
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
- name: Windows streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Test the bundled Python with the native PC libraries
|
||||
if: runner.os != 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
platform=win; arch=x64
|
||||
python="$PWD/app/build/deps/win-x64/python/python.exe"
|
||||
native="$(cygpath -w "$PWD/app/build/desktop/win-x64")"
|
||||
else
|
||||
platform=linux; arch=x64
|
||||
if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python="$PWD/app/build/deps/linux-$arch/python/bin/python3"
|
||||
native="$PWD/app/build/desktop/linux-$arch"
|
||||
fi
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 "$python" -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
|
||||
-10
@@ -7,15 +7,5 @@ compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Shared desktop streaming controller build products
|
||||
desktop/controller.dll
|
||||
desktop/controller.dylib
|
||||
desktop/controller.so
|
||||
desktop/*.obj
|
||||
desktop/*.lib
|
||||
desktop/*.exp
|
||||
desktop/bundle/
|
||||
app/build/desktop/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
@@ -218,7 +218,6 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Windows and Linux host implementation and test coverage |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
|
||||
@@ -11,6 +11,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -28,6 +29,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
@@ -280,6 +282,8 @@ 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("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// 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;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.9",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+6
-24
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.9",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -11,9 +11,8 @@
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 && electron-builder --linux --x64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:linux:arm64": "node build/fetch-deps.js linux arm64 && electron-builder --linux --arm64 --publish never"
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -40,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -185,16 +185,7 @@
|
||||
"entry": {
|
||||
"StartupWMClass": "frame-control"
|
||||
}
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
@@ -207,16 +198,7 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
function read() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||
} catch { return {}; }
|
||||
}
|
||||
function write(key, value) {
|
||||
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||
const saved = { ...read(), [key]: value };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
module.exports = { preferenceStore };
|
||||
@@ -11,6 +11,10 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
preferences: {
|
||||
read: () => ipcRenderer.invoke("preferences:read"),
|
||||
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { preferenceStore } = require("../preferences");
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, "client-preferences.json");
|
||||
}
|
||||
|
||||
test("desktop packaging includes every local application module dependency", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const packaged = new Set(require("../package.json").build.files);
|
||||
for (const file of ["main.js", "preload.js"]) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||
const target = match[1].slice(2) + ".js";
|
||||
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("client choices survive a new native session and local server origin", t => {
|
||||
const file = fixture(t);
|
||||
const first = preferenceStore(file);
|
||||
assert.deepEqual(first.read(), {});
|
||||
first.write("mvSeparate", "0");
|
||||
first.write("padUsed", "1");
|
||||
// A new process/session reads the same client store, regardless of its URL.
|
||||
const second = preferenceStore(file);
|
||||
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||
second.write("mvSeparate", "1");
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the two non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
}
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("a missing or damaged preference file retains usable defaults", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.deepEqual(store.read(), {});
|
||||
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||
fs.writeFileSync(file, bad);
|
||||
assert.deepEqual(store.read(), {});
|
||||
}
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-hairpin",
|
||||
"date": "2026-09-28T22:36:19",
|
||||
"commit": "c5fc552+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 351,
|
||||
"frames_drawn": 351,
|
||||
"frames_shown": 324,
|
||||
"duration_s": 10.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 351
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 351
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.9,
|
||||
"p99": 3.5,
|
||||
"n": 351
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.4,
|
||||
"p99": 0.6,
|
||||
"n": 351
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.6,
|
||||
"p95": 75.7,
|
||||
"p99": 130.1,
|
||||
"n": 351
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.1,
|
||||
"p99": 4.1,
|
||||
"n": 351
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.1,
|
||||
"n": 351
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 18.1,
|
||||
"p99": 24.9,
|
||||
"n": 324
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 80.3,
|
||||
"p99": 134.2,
|
||||
"n": 351
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.0,
|
||||
"p95": 71.8,
|
||||
"p99": 146.3,
|
||||
"n": 324
|
||||
}
|
||||
},
|
||||
"fps": 34.8,
|
||||
"fps_shown": 32.0,
|
||||
"late_pct": 53.41,
|
||||
"stall_max": 226.8,
|
||||
"stalls_over_100ms": 7,
|
||||
"mbps": 0.26,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 607,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.3,
|
||||
"p95": 98.4,
|
||||
"p99": 110.1,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 54.7,
|
||||
"p95": 98.9,
|
||||
"p99": 114.0,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 17.1,
|
||||
"p95": 48.3,
|
||||
"p99": 53.1,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 12.5,
|
||||
"p95": 32.9,
|
||||
"p99": 35.7,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 16.8,
|
||||
"p95": 50.0,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 18,
|
||||
"sent": 18,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 38,
|
||||
"mbps": 0.3,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 33,
|
||||
"mbps": 0.24,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 103.5,
|
||||
"bitrate": 3845448,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 39,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 29.9,
|
||||
"bitrate": 5283790,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 25,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 17.8,
|
||||
"content_p95": 175.9,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 34,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 69.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 17.7,
|
||||
"content_p95": 68.7,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 38,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 18.2,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 30,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 111.0,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 35,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 13.4,
|
||||
"content_p95": 102.6,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 12.3,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 3,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 20.9,
|
||||
"content_p95": 24.7,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 80.3,
|
||||
"input_p50": 43.3,
|
||||
"input_p95": 98.4,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"target": 3442081,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 13.334,
|
||||
"inFlight": 1,
|
||||
"slack": 71.738,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 3.77,
|
||||
"e": "target 2764800 bit/s; tier 0"
|
||||
},
|
||||
{
|
||||
"t": 7.42,
|
||||
"e": "target 1725204 bit/s; tier 0"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
|
||||
"show_s": 1.88,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-final",
|
||||
"date": "2026-09-28T22:55:27",
|
||||
"commit": "cd20243",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "native",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 400,
|
||||
"frames_drawn": 400,
|
||||
"frames_shown": 339,
|
||||
"duration_s": 10.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 400
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.7,
|
||||
"p99": 3.1,
|
||||
"n": 400
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.8,
|
||||
"p95": 25.2,
|
||||
"p99": 37.8,
|
||||
"n": 400
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.5,
|
||||
"p99": 2.0,
|
||||
"n": 400
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.7,
|
||||
"p99": 1.0,
|
||||
"n": 400
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 19.3,
|
||||
"p99": 26.8,
|
||||
"n": 339
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 28.6,
|
||||
"p99": 41.3,
|
||||
"n": 400
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.6,
|
||||
"p95": 39.0,
|
||||
"p99": 47.2,
|
||||
"n": 339
|
||||
}
|
||||
},
|
||||
"fps": 39.3,
|
||||
"fps_shown": 33.4,
|
||||
"late_pct": 50.25,
|
||||
"stall_max": 69.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.35,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 612,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 76.6,
|
||||
"p95": 98.2,
|
||||
"p99": 127.8,
|
||||
"n": 19
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 80.5,
|
||||
"p95": 107.8,
|
||||
"p99": 128.1,
|
||||
"n": 16
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 36.5,
|
||||
"p95": 57.6,
|
||||
"p99": 83.2,
|
||||
"n": 19
|
||||
},
|
||||
"mac": {
|
||||
"p50": 14.6,
|
||||
"p95": 31.3,
|
||||
"p99": 31.7,
|
||||
"n": 19
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.1,
|
||||
"p95": 32.8,
|
||||
"p99": 36.2,
|
||||
"n": 19
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 19,
|
||||
"sent": 19,
|
||||
"seen": 19
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 40,
|
||||
"mbps": 0.34,
|
||||
"content_p50": 11.7,
|
||||
"content_p95": 27.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 11.9,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 41,
|
||||
"mbps": 0.35,
|
||||
"content_p50": 11.4,
|
||||
"content_p95": 16.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 12.7,
|
||||
"content_p95": 28.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 38,
|
||||
"mbps": 0.33,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 41,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 29.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 41,
|
||||
"mbps": 0.31,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 38,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 25.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 6,
|
||||
"mbps": 0.03,
|
||||
"content_p50": 17.6,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 28.6,
|
||||
"input_p50": 76.6,
|
||||
"input_p95": 98.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "acceptable",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"target": 5529600,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 12.332,
|
||||
"inFlight": 0,
|
||||
"slack": 74.12,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
|
||||
"show_s": 1.75,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
|
||||
out = root / ('controller' + suffix)
|
||||
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
|
||||
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
|
||||
else:
|
||||
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
print(out)
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build our native PC adapter and bundle its ordinary shared libraries.
|
||||
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
if not win:
|
||||
modules += ['gio-unix-2.0']
|
||||
import shlex
|
||||
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
|
||||
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
|
||||
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
|
||||
if not win:
|
||||
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
|
||||
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
|
||||
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
|
||||
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad', 'x264']
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
|
||||
if not matches:
|
||||
raise SystemExit('Missing GStreamer library: ' + name)
|
||||
for source in matches:
|
||||
target = plugins_out / source.name
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
if not win:
|
||||
# PipeWire dynamically loads support and protocol modules. ldd cannot
|
||||
# discover those; bundle them with a private client-only config instead
|
||||
# of accidentally loading a different distro's client modules/config.
|
||||
libdir = plugin_dir.parent
|
||||
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
|
||||
'pipewire-0.3/libpipewire-module-client-node.so',
|
||||
'pipewire-0.3/libpipewire-module-adapter.so',
|
||||
'spa-0.2/support/libspa-support.so',
|
||||
'spa-0.2/support/libspa-dbus.so',
|
||||
'spa-0.2/videoconvert/libspa-videoconvert.so'):
|
||||
source = libdir / relative
|
||||
if not source.is_file():
|
||||
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
|
||||
target = destination / 'lib' / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
config = destination / 'share' / 'pipewire'
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / 'client.conf').write_text(
|
||||
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
|
||||
'context.modules = [\n'
|
||||
' { name = libpipewire-module-protocol-native }\n'
|
||||
' { name = libpipewire-module-client-node }\n'
|
||||
' { name = libpipewire-module-adapter }\n'
|
||||
']\n')
|
||||
copied = set()
|
||||
while pending:
|
||||
binary = pending.pop()
|
||||
if win:
|
||||
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
|
||||
dependencies = [prefix / 'bin' / name for name in imported]
|
||||
else:
|
||||
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
|
||||
for dep in dependencies:
|
||||
if not dep.is_file() or dep.name in copied:
|
||||
continue
|
||||
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
|
||||
continue
|
||||
shutil.copy2(dep, libs_out / dep.name)
|
||||
copied.add(dep.name)
|
||||
pending.append(dep)
|
||||
# License texts and package provenance accompany the dynamically linked
|
||||
# libraries. Distribution builders keep the upstream package/source URLs.
|
||||
licenses = destination / 'licenses'
|
||||
licenses.mkdir(exist_ok=True)
|
||||
if win:
|
||||
source = prefix / 'share' / 'licenses'
|
||||
if source.exists():
|
||||
shutil.copytree(source, licenses, dirs_exist_ok=True)
|
||||
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
|
||||
else:
|
||||
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
|
||||
# Debian copyright files contain licenses and source homepage details.
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
build(parser.parse_args().out.resolve())
|
||||
@@ -1,125 +0,0 @@
|
||||
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
|
||||
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
|
||||
#include "controller.h"
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video-event.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
|
||||
typedef struct {
|
||||
GstElement *pipeline, *encoder, *sink, *raw_queue;
|
||||
Gate gate;
|
||||
GstSample *sample;
|
||||
GstMapInfo map;
|
||||
int mapped;
|
||||
char error[512];
|
||||
} Capture;
|
||||
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
|
||||
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
|
||||
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
|
||||
FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
int64_t now=fc_now(), cap=now;
|
||||
GstClock *clock=gst_element_get_clock(c->pipeline);
|
||||
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
|
||||
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
|
||||
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
|
||||
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
|
||||
}
|
||||
if(clock)gst_object_unref(clock);
|
||||
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
|
||||
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
int phase=0;
|
||||
for(;;) {
|
||||
/* While congested, prefer a newer raw picture queued upstream. If
|
||||
* nothing changed, retain this last picture until the gate opens;
|
||||
* an idle window must not stay stale after a dropped final update. */
|
||||
guint queued=0;
|
||||
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
|
||||
if(queued)return GST_PAD_PROBE_DROP;
|
||||
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
|
||||
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
phase=2;g_usleep(2000);
|
||||
}
|
||||
}
|
||||
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
|
||||
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
|
||||
c->pipeline=gst_parse_launch(pipeline,&e);
|
||||
if(e || !c->pipeline) {
|
||||
g_strlcpy(error,e?e->message:"No pipeline",capacity);
|
||||
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
|
||||
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
|
||||
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
|
||||
if(!c->encoder || !c->sink || !raw) {
|
||||
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
|
||||
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
|
||||
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
|
||||
GstPad *p=gst_element_get_static_pad(raw,"src");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
|
||||
p=gst_element_get_static_pad(c->encoder,"sink");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
|
||||
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
|
||||
return c;
|
||||
}
|
||||
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
|
||||
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
|
||||
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
|
||||
GstBus *bus=gst_element_get_bus(c->pipeline);
|
||||
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
|
||||
if(m) {
|
||||
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
|
||||
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
|
||||
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
|
||||
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
|
||||
gst_message_unref(m);return -1;
|
||||
}
|
||||
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
|
||||
if(!c->sample) {
|
||||
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
|
||||
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
GstBuffer *b=gst_sample_get_buffer(c->sample);
|
||||
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
|
||||
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
|
||||
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
|
||||
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
|
||||
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
|
||||
}
|
||||
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
|
||||
FC_API void fc_capture_bitrate(Capture *c,int bps) {
|
||||
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
|
||||
}
|
||||
FC_API void fc_capture_test(Capture *c,uint32_t input) {
|
||||
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
|
||||
if(source) {
|
||||
/* Each benchmark click visibly changes the ball, before injection is
|
||||
* timestamped. This is a response, not merely a protocol input echo. */
|
||||
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
|
||||
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
|
||||
}
|
||||
}
|
||||
FC_API void fc_capture_key(Capture *c) {
|
||||
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
|
||||
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
|
||||
}
|
||||
FC_API void fc_capture_close(Capture *c) {
|
||||
gst_element_set_state(c->pipeline,GST_STATE_NULL);
|
||||
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
|
||||
if(c->sample)gst_sample_unref(c->sample);
|
||||
if(c->raw_queue)gst_object_unref(c->raw_queue);
|
||||
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/* Extracted from the Mac agent's RateController. Same gate, demand protection,
|
||||
* bitrate recovery and tier hysteresis for every host. Bounded sample buffers. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CAP 1024
|
||||
#define MIN(a,b) ((a)<(b)?(a):(b))
|
||||
#define MAX(a,b) ((a)>(b)?(a):(b))
|
||||
typedef struct { int64_t t, v; uint32_t seq; } Sample;
|
||||
typedef struct { Sample a[CAP]; int n; } Samples;
|
||||
struct FCController {
|
||||
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
|
||||
int64_t decrease, increase, below, above, slack;
|
||||
Samples flight, rtts, acked, sent, captures;
|
||||
};
|
||||
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
|
||||
static const double floors[] = {.45,.28,.16,.08,0};
|
||||
static void remove_first(Samples *s, int n) {
|
||||
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
|
||||
}
|
||||
static void add(Samples *s, Sample v, int cap) {
|
||||
if (s->n >= cap) remove_first(s, 1);
|
||||
s->a[s->n++] = v;
|
||||
}
|
||||
static void expire(Samples *s, int64_t oldest) {
|
||||
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
|
||||
remove_first(s,n);
|
||||
}
|
||||
static int64_t base(FCController *c) {
|
||||
int64_t v=0;
|
||||
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
|
||||
return v;
|
||||
}
|
||||
static int compare(const void *a, const void *b) {
|
||||
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
|
||||
int64_t a[CAP]; int n=0;
|
||||
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
|
||||
if(!n) return 0;
|
||||
qsort(a,(size_t)n,sizeof(int64_t),compare);
|
||||
return a[n*numerator/denominator];
|
||||
}
|
||||
static int rate(Samples *s) {
|
||||
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
|
||||
return (int)MIN(total*16,2147483647);
|
||||
}
|
||||
FCController *fc_new(int max_fps,int enabled) {
|
||||
FCController *c=calloc(1,sizeof(*c));
|
||||
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
|
||||
return c;
|
||||
}
|
||||
void fc_free(FCController *c) {free(c);}
|
||||
void fc_ceiling(FCController *c,int bps) {
|
||||
if(!c->target || c->target>bps) c->target=bps;
|
||||
c->ceiling=bps;
|
||||
}
|
||||
int fc_gate(FCController *c,int64_t now,int counts) {
|
||||
if(!c->enabled || !c->saw_ack) return 1;
|
||||
expire(&c->flight,now-2000000);
|
||||
if(!c->flight.n) return 1;
|
||||
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
|
||||
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
|
||||
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
|
||||
if(counts) c->held++;
|
||||
return 0;
|
||||
}
|
||||
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
|
||||
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
|
||||
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
|
||||
}
|
||||
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
|
||||
c->saw_ack=1;
|
||||
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
|
||||
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
|
||||
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
|
||||
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int fc_update(FCController *c,int64_t now) {
|
||||
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
|
||||
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
|
||||
expire(&c->captures,now-1000000);
|
||||
if(!c->enabled || c->ceiling<=0) return 0;
|
||||
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
|
||||
int64_t jitter=spread ? spread-baseline : 0;
|
||||
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
|
||||
int64_t recent=quantile(&c->rtts,now-300000,1,2);
|
||||
int64_t queue=recent ? recent-baseline : 0;
|
||||
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
|
||||
int delivered=rate(&c->acked),sending=rate(&c->sent);
|
||||
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
|
||||
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
|
||||
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
|
||||
int congested=signal && c->signal;c->signal=signal;c->held=0;
|
||||
if(congested && now-c->decrease>300000) {
|
||||
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
|
||||
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
|
||||
c->target=next;c->decrease=now;
|
||||
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
|
||||
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
|
||||
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
|
||||
}
|
||||
double share=(double)c->target/MAX(c->ceiling,1);
|
||||
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
|
||||
if(!c->below)c->below=now;
|
||||
if(now-c->below>500000) {
|
||||
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
|
||||
c->below=0;
|
||||
}
|
||||
} else c->below=0;
|
||||
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
|
||||
if(!c->above)c->above=now;
|
||||
if(now-c->above>2000000) {c->tier--;c->above=0;}
|
||||
} else c->above=0;
|
||||
return c->target;
|
||||
}
|
||||
int64_t fc_value(FCController *c,int field) {
|
||||
switch(field) {
|
||||
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
|
||||
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
|
||||
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/* Shared latency controller. Times are monotonic host microseconds.
|
||||
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
|
||||
* encoded inter frame would break the decoder's reference chain. */
|
||||
#ifndef FRAME_CONTROLLER_H
|
||||
#define FRAME_CONTROLLER_H
|
||||
#include <stdint.h>
|
||||
#ifdef _WIN32
|
||||
#define FC_API __declspec(dllexport)
|
||||
#else
|
||||
#define FC_API
|
||||
#endif
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct FCController FCController;
|
||||
FC_API FCController *fc_new(int fps, int enabled);
|
||||
FC_API void fc_free(FCController *c);
|
||||
FC_API void fc_ceiling(FCController *c, int bps);
|
||||
FC_API int fc_gate(FCController *c, int64_t now, int counts);
|
||||
FC_API void fc_capture(FCController *c, int64_t now);
|
||||
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
|
||||
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
|
||||
FC_API int fc_update(FCController *c, int64_t now);
|
||||
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
|
||||
FC_API int64_t fc_value(FCController *c, int field);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -1,147 +0,0 @@
|
||||
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
|
||||
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
|
||||
* Input uses only the devices granted in Start's response. */
|
||||
#ifndef _WIN32
|
||||
#include "controller.h"
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#define BUS "org.freedesktop.portal.Desktop"
|
||||
#define PATH "/org/freedesktop/portal/desktop"
|
||||
#define RD "org.freedesktop.portal.RemoteDesktop"
|
||||
#define SC "org.freedesktop.portal.ScreenCast"
|
||||
typedef struct {
|
||||
GDBusConnection *bus;
|
||||
GMainContext *context;
|
||||
char *session;
|
||||
int fd;
|
||||
unsigned node, devices;
|
||||
int width,height;
|
||||
} Portal;
|
||||
typedef struct {GVariant *result;int done;unsigned code;} Response;
|
||||
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
|
||||
const char *signal,GVariant *params,gpointer data) {
|
||||
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
|
||||
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
|
||||
}
|
||||
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
|
||||
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
|
||||
const char *token,char *error,int capacity) {
|
||||
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
|
||||
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
|
||||
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
|
||||
Response r={0};GError *e=NULL;
|
||||
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
|
||||
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
|
||||
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
|
||||
if(reply)g_variant_unref(reply);
|
||||
int64_t deadline=g_get_monotonic_time()+120000000;
|
||||
while(!e && !r.done && g_get_monotonic_time()<deadline) {
|
||||
while(g_main_context_iteration(p->context,FALSE)) {}
|
||||
g_usleep(10000);
|
||||
}
|
||||
if(!r.done) {
|
||||
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(closed)g_variant_unref(closed);
|
||||
}
|
||||
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
|
||||
if(e || !r.done || r.code) {
|
||||
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
|
||||
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
|
||||
}
|
||||
return r.result;
|
||||
}
|
||||
FC_API void fc_portal_close(Portal *p) {
|
||||
if(!p)return;
|
||||
if(p->session && p->bus) {
|
||||
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(r)g_variant_unref(r);
|
||||
}
|
||||
if(p->fd>=0){close(p->fd);p->fd=-1;}
|
||||
g_free(p->session);if(p->bus)g_object_unref(p->bus);
|
||||
if(p->context)g_main_context_unref(p->context);g_free(p);
|
||||
}
|
||||
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
|
||||
* previously consumed protocol socket is not a new connection.
|
||||
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
|
||||
* pipewiresrc never takes the fd it is given: its core connects with
|
||||
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
|
||||
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
|
||||
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
|
||||
* close; not closing it would leak one socket per pipeline reopen. The
|
||||
* caller closes the previous pipeline before asking for a new fd. */
|
||||
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
|
||||
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
GUnixFDList *fds=NULL;GError *e=NULL;
|
||||
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
|
||||
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
|
||||
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
|
||||
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
|
||||
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
if(p->fd>=0)close(p->fd);
|
||||
p->fd=fd;return fd;
|
||||
}
|
||||
FC_API Portal *fc_portal_select(char *error,int capacity) {
|
||||
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
|
||||
g_main_context_push_thread_default(p->context);
|
||||
GError *e=NULL;GVariant *r=NULL;
|
||||
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
|
||||
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
|
||||
GVariantBuilder b;char token[64],session[64];
|
||||
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
|
||||
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
|
||||
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
|
||||
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
|
||||
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));
|
||||
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"devices","u",&p->devices);
|
||||
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
|
||||
if(streams && g_variant_n_children(streams)>0) {
|
||||
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
|
||||
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
|
||||
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
|
||||
g_variant_unref(props);g_variant_unref(entry);
|
||||
}
|
||||
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
|
||||
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
|
||||
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
|
||||
g_main_context_pop_thread_default(p->context);return p;
|
||||
fail:
|
||||
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
|
||||
}
|
||||
FC_API int fc_portal_value(Portal *p,int field) {
|
||||
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
|
||||
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
|
||||
}
|
||||
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
|
||||
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
if(type==0 && (p->devices&2)) {
|
||||
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
|
||||
} else if(type==1 && (p->devices&2)) {
|
||||
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else if(type==2 && (p->devices&2)) {
|
||||
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
|
||||
} else if(type==3 && (p->devices&1)) {
|
||||
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else return 0;
|
||||
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
|
||||
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
|
||||
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
# Import an Android game backup
|
||||
|
||||
A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
|
||||
The import screen shows the game app and its game-data files together. **Import
|
||||
game & data** installs the APK, starts its own Steam library instance, waits for
|
||||
Android and the installed package, copies the OBBs, and closes the game. Open it
|
||||
again from the Steam library to play. The screen explains this before import;
|
||||
choosing files alone never starts a game.
|
||||
|
||||
Files are matched using the package inside the APK and the standard
|
||||
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
|
||||
patch files are preserved. OBB version numbers need not equal the APK's current
|
||||
version code: updates can reuse older expansion files. The copy uses the existing
|
||||
SHA-256 transfer check inside Android and writes to that game's own
|
||||
`/sdcard/Android/obb/<package>` folder.
|
||||
|
||||
If no data files are selected, the screen reminds you to include any OBBs in the
|
||||
backup before offering **Install game app**. An APK cannot reliably tell Frame
|
||||
Control whether separate expansion files are required. Game-data files are never
|
||||
silently sent to Downloads by this flow.
|
||||
|
||||
If setup fails, the import screen keeps the error and retry instructions visible.
|
||||
The APK may already be installed, but the game is not reported ready. Import the
|
||||
same backup again, or choose just its OBB files to add data to an existing install.
|
||||
The importer checks the installed package and version before writing data and
|
||||
closes the instance after a failed copy. Unrelated or duplicate files, running
|
||||
games that would be replaced, and downgrades are rejected. Uploaded staging files
|
||||
are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
matching, sibling discovery, browser upload staging and cleanup, instance/version
|
||||
guards and failure recovery with mocked headset operations. Browser verification
|
||||
uses the real page and upload/inspection endpoints with simulated installation.
|
||||
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
|
||||
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
|
||||
install/start/copy/close sequence. Reopening its persistent instance confirmed
|
||||
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
|
||||
shortcut and instance were removed afterward, the shared device lock was
|
||||
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
@@ -183,6 +183,63 @@ See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## Store and Library in Frame Control
|
||||
|
||||
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
|
||||
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
|
||||
*Social Network* is Web & chat).
|
||||
|
||||
Apps built around a phone are hidden from browsing unless **Show phone-only
|
||||
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
|
||||
a category that only makes sense in a phone's own system (launchers, keyboards,
|
||||
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
|
||||
fitness, alarms, system tools) unless a useful category says otherwise
|
||||
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
|
||||
part (a widget, a launcher, a battery tool), and apps that need hardware the
|
||||
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
|
||||
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
|
||||
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
|
||||
|
||||
Without a search, each source offers its best 300 for the chosen category:
|
||||
apps with a banner first, then with screenshots, then the newest.
|
||||
|
||||
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
|
||||
|
||||
The **APK Store** panel is the Store above, drawn for the laser: the same
|
||||
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
|
||||
its own floating panel:
|
||||
|
||||
```sh
|
||||
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
scripts/store-on-frame.sh close
|
||||
```
|
||||
|
||||
`open` copies the same bundle the iPhone app uses
|
||||
(`ios/scripts/make_frame_bundle.py`) to
|
||||
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
|
||||
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
|
||||
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
|
||||
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
|
||||
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
|
||||
all run on the Frame, so the Mac can go away after `open`.
|
||||
|
||||
The page has a category rail (Home, the store's categories, Installed), eight
|
||||
art cards a page, and a featured VR app on Home. It turns pages instead of
|
||||
scrolling, because a tagged panel gets mouse clicks and a drag would select
|
||||
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
|
||||
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
|
||||
the real sources): Home, categories, Installed, the detail sheet. **Still to
|
||||
check in the headset:** that the panel opens and takes laser clicks, that an
|
||||
install completes when run on the Frame, and that **Open** brings up the app's
|
||||
panel.
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-20T03:53:25Z",
|
||||
"build_id": "20260925.6191901",
|
||||
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
|
||||
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
|
||||
"sibling_discovery": true,
|
||||
"automatic_import": "passed",
|
||||
"closed_after_import": true,
|
||||
"independent_readback": [
|
||||
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
|
||||
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
|
||||
],
|
||||
"cleanup": {
|
||||
"test_install_removed": true,
|
||||
"running_instances_before": {},
|
||||
"running_instances_after": {},
|
||||
"battery_before": [
|
||||
"100",
|
||||
"Full",
|
||||
"Charging"
|
||||
],
|
||||
"battery_after": [
|
||||
"99",
|
||||
"Discharging",
|
||||
"Charging"
|
||||
],
|
||||
"shared_lock_released": true
|
||||
},
|
||||
"limits": [
|
||||
"No headset rendering or gameplay assertion; headset unworn.",
|
||||
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
|
||||
"A game that exits before its Lepton instance can accept data was not tested."
|
||||
]
|
||||
}
|
||||
@@ -139,7 +139,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
sources and artwork. Android display links reveal the **Library** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
# PC in the headset
|
||||
|
||||
Windows and Linux hosts use **Tools → PC in the headset**. The host shares a
|
||||
window or screen, and the existing Frame viewer makes it a SteamVR panel.
|
||||
Move it with the dashboard's Float in World, Move and Size controls.
|
||||
|
||||
**Inferred / not yet verified on a desktop host:** the Windows and Linux
|
||||
capture and input paths below. This work was developed on a Mac with no
|
||||
Windows or Linux desktop VM. A native build or test-pattern test in CI does
|
||||
not establish that desktop capture, a permission dialog, hardware encoding
|
||||
or laser input works. Keep this feature in the draft/testing stage until
|
||||
those paths have been tried on real hosts.
|
||||
|
||||
## Own implementation, platform APIs and bundled libraries
|
||||
|
||||
Frame Control owns the host agent, input routing, authentication, streaming
|
||||
protocol, panel launcher and adaptation. It does not launch or require
|
||||
Sunshine, OBS or another desktop-streaming app. GStreamer and its codec
|
||||
plugins are ordinary libraries bundled with the Windows and Linux app;
|
||||
users do not install a GStreamer application. The shared library build keeps
|
||||
license texts and package provenance alongside the libraries. Linux also
|
||||
bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a
|
||||
private client configuration; it does not change the desktop’s configuration.
|
||||
|
||||
First-party alternatives considered (**documented**): Valve Remote Play
|
||||
streams a game/desktop, rather than providing this per-window panel protocol;
|
||||
Windows Remote Desktop opens a remote session; Linux's desktop portal is the
|
||||
consent mechanism for sharing the current desktop. The chosen paths are:
|
||||
|
||||
| Host | Capture | Encoding | Input |
|
||||
|---|---|---|---|
|
||||
| Windows | Windows.Graphics.Capture, through `d3d11screencapturesrc capture-api=wgc`; HWND or HMONITOR | Hardware Media Foundation (`mfh264enc`), low latency, no B-frames | `SendInput`, with source bounds and per-monitor DPI awareness |
|
||||
| Linux | RemoteDesktop + ScreenCast portal, then the returned PipeWire fd/node | VA-API (`vah264enc`) where registered; x264 otherwise | RemoteDesktop portal notifications, using only granted pointer/keyboard devices |
|
||||
|
||||
API choices are **documented**, not device verification:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html),
|
||||
[ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html),
|
||||
[RemoteDesktop portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html).
|
||||
|
||||
Windows needs a WGC-capable Windows 10/11 desktop and an available hardware
|
||||
Media Foundation H.264 encoder. Elevated windows and the secure desktop
|
||||
cannot be driven by an ordinary Frame Control process. If Windows refuses
|
||||
to focus a selected window, input is refused rather than sent to the app
|
||||
covering it; bring the selected window forward, then Stop and Show again. Minimized/closed
|
||||
windows may stop producing frames. Protected content is not supported.
|
||||
|
||||
On Linux, press **Choose a window or screen…** and approve the desktop's
|
||||
sharing dialog. Choose another source to add another panel. Stop releases
|
||||
that source's portal session; sharing it again asks for consent again.
|
||||
A desktop must implement both ScreenCast and RemoteDesktop for this path;
|
||||
a ScreenCast-only compositor cannot provide laser input through this API.
|
||||
Cancelling or denying a dialog is reported on the card. No portal permission
|
||||
is bypassed, and Frame Control does not open `/dev/uinput` or the unrestricted
|
||||
PipeWire daemon on the host.
|
||||
|
||||
The host's own keyboard still works. Input from the viewer uses normalized
|
||||
picture coordinates, maps through the selected source's bounds, and releases
|
||||
held buttons/keys on blur, disconnect and Stop. Linux requires the pointer
|
||||
and keyboard grants. **Untested:** desktop-specific consent, mixed-DPI
|
||||
Windows input alignment, multi-monitor layouts, hardware encoder behavior,
|
||||
window resize/minimize, and non-US keyboard layouts.
|
||||
|
||||
## Shared pieces
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnect supervision, quality
|
||||
presets and panel launch for all hosts. `ui/frame_pcview.py` selects the PC
|
||||
helper; `/api/macview` remains the compatible endpoint.
|
||||
- `ui/mac-view.html` is the one viewer. The 17-byte big-endian frame header,
|
||||
Annex-B H.264/JPEG payloads, `hello`/`ack` reconnect handshake, clock sync,
|
||||
`rx`/`fd` timing reports and input messages are unchanged.
|
||||
- `desktop/controller.c` is the rate controller shared by the Mac Swift
|
||||
binding and the PC Python binding. Capture is gated **before** encoding;
|
||||
encoded reference frames are never discarded. A bounded raw-frame queue
|
||||
keeps the newest picture, including the last update of an idle window,
|
||||
until the gate opens. A native one-frame-source test covers that case.
|
||||
It keeps the Mac's bitrate demand protection and tier hysteresis.
|
||||
- PC records use the existing `Stats.swift` JSON schema, with bounded
|
||||
4096-frame/512-input storage in `ui/frame_stream_stats.py`. The benchmark's
|
||||
analysis, targets and network shaping are shared, not reimplemented.
|
||||
Capture timestamps describe the native pipeline's source time; they do
|
||||
not prove the time at which the host compositor displayed the pixels.
|
||||
- Mac virtual-display separation remains Mac-only. Windows WGC and the
|
||||
Linux portal share the selected window directly.
|
||||
|
||||
PC capture follows the shared frame-rate and resolution tiers. x264 updates
|
||||
bitrate while running. Hardware encoders are drained and reopened when the
|
||||
budget changes materially, at most once a second, because their live property
|
||||
support varies. Reopening starts a new keyframe and retains the consented
|
||||
portal session. **Untested:** hardware reconfiguration latency and whether a
|
||||
particular desktop permits reconnecting its PipeWire stream this way.
|
||||
|
||||
## Build and measure
|
||||
|
||||
Packaged Windows/Linux builds include `desktop/bundle/pc-host` and its shared
|
||||
libraries. Source checkouts build them with `python3 desktop/build.py` after
|
||||
installing GStreamer development packages (see the `PC host libraries` CI
|
||||
workflow). The feature reports a missing bundle; it does not download or
|
||||
install a streaming app on first use.
|
||||
|
||||
The existing benchmark now accepts a PC host:
|
||||
|
||||
```sh
|
||||
python3 scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
# Linux: select a real source in the desktop's sharing dialog
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source choose --label linux-window
|
||||
# Windows: use the HWND/monitor source ID shown by the host's /windows or /displays
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
The synthetic PC pattern uses bundled x264 so headless CI can verify the
|
||||
wire protocol without claiming that a GPU was exercised. The `capture`
|
||||
scenario measures the selected real source without injecting input or
|
||||
assuming that it animates at 60 fps. Mac-only Chrome/virtual-display typing
|
||||
and scrolling automation is not run on PC hosts. Results retain the same
|
||||
latency stages and record `host_platform`, `pc_host` and `source`. CPU sampling
|
||||
on PC hosts is explicitly unavailable. `--net` and `--delay` still use the
|
||||
same bounded shaping relay, without administrator privileges.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **Verified, Mac, 2026-09-28:** the final full unit/integration suite ran
|
||||
183 tests successfully, including the real Mac helper's H.264, ticket, timing
|
||||
and input-echo tests. The native PC test class was skipped locally because
|
||||
its libraries were absent; the PC adapter and shared-controller tests passed.
|
||||
- **Verified, real Frame, 2026-09-28, BUILD_ID 20260925.6191901:** the base
|
||||
helper's synthetic source created panel `valve.steam.desktopgame.2001639889`,
|
||||
and the shared Chromium viewer decoded H.264. It recorded 286 frames over
|
||||
the short probe, with a two-second summary of 19.5 fps shown and total
|
||||
latency p50/p95 83/156.5 ms. This establishes the existing viewer/transport
|
||||
route, not Windows/Linux capture, input or a latency target. The probe's
|
||||
helper, tunnel and viewer were stopped afterward.
|
||||
- **Verified in CI:** native library builds and real x264 protocol tests passed
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64 in
|
||||
[run 36422214445](https://github.com/saphid/frame-control/actions/runs/36422214445).
|
||||
All four installer builds passed in
|
||||
[run 36422214425](https://github.com/saphid/frame-control/actions/runs/36422214425).
|
||||
These are build/synthetic tests, not desktop-host verification. No VM was used.
|
||||
- **Verified, real Frame, same build/date:** the ARM64 PC agent and its
|
||||
bundled libraries ran from a temporary user directory, using x264's moving
|
||||
test pattern. Traffic travelled Frame → Mac SSH relay → Frame viewer.
|
||||
The shared bench recorded 351 drawn frames, content p50/p95 15.1/80.3 ms,
|
||||
and 34.8 fps. The frame-rate and late-frame targets failed. This checks the
|
||||
new agent and real viewer together; it is not a representative PC link.
|
||||
The helper exited 0, its viewer/tunnels stopped, and its directory was removed.
|
||||
[Raw benchmark result](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
This first probe's input echo measured message receipt to the next capture,
|
||||
not a visible pattern response; later builds make test clicks change its color.
|
||||
- **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace
|
||||
matched the original Swift controller. `tests/test_pc_controller.py` retains
|
||||
the original trace digest as a regression check.
|
||||
- **Verified, real Frame, agent at `cd20243`:** the repeated synthetic
|
||||
probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic
|
||||
input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before
|
||||
injection is timestamped. The frame-rate/late-frame targets still failed;
|
||||
this remains a Frame-hosted x264 test through a Mac relay, not a desktop or
|
||||
physical-laser measurement. Helper exit 0 and cleanup succeeded.
|
||||
[Latest device probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json).
|
||||
- **Untested:** real Windows WGC → Media Foundation → Frame; real Linux
|
||||
portal → PipeWire → VA-API/x264 → Frame; physical laser input on either.
|
||||
No benchmark numbers for those desktop paths are claimed.
|
||||
|
||||
## Independent review availability
|
||||
|
||||
A direct read-only review was attempted with
|
||||
`devin -p --model swe-2-max --permission-mode auto --prompt-file …`. The first
|
||||
attempt exited 0 after rejecting a tool that needed interactive permission;
|
||||
it did not inspect the diff. A full inline-diff attempt returned no output
|
||||
for 15 minutes and was terminated (shell exit 143). A smaller inline native
|
||||
code review returned no output within 300 seconds (process exit -15).
|
||||
SWE-2 Max was requested; no completed review or findings were received, so
|
||||
independent review is **unverified**, not a passed check. The PR remains draft.
|
||||
|
||||
## Device follow-up, 2026-09-29
|
||||
|
||||
**Verified, read-only, SteamOS BUILD_ID 20260925.6191901:** the Frame was
|
||||
reachable, charging (44% initially, 63% at the end), and SteamVR reported
|
||||
activity level 3 (standby). No Plasma desktop was running. Introspection of
|
||||
the active `org.freedesktop.portal.Desktop` service exposed ScreenCast with
|
||||
source types 3, but no RemoteDesktop interface. **Inferred:** this gamescope
|
||||
session cannot provide this feature's required consented input path; a
|
||||
ScreenCast-only session is insufficient.
|
||||
|
||||
**Blocked, not a device execution result:** the shared `/tmp/frame-test.lock`
|
||||
was held by another thread. Ten lock-acquisition attempts over five minutes
|
||||
all failed, after earlier preparation-time attempts also found it occupied.
|
||||
The current `a992f6d` ARM64 CI bundle was downloaded to the Mac, but nothing
|
||||
was installed, launched or stopped on the Frame. No new screenshots or
|
||||
current-revision device timings were obtained. The lock was not removed and
|
||||
no global settings were changed. The earlier synthetic results above remain
|
||||
valid only for their named revisions; standby visibility, physical laser
|
||||
input and worn-headset performance remain unverified.
|
||||
|
||||
**Verified, Mac:** a fresh full suite ran 309 tests successfully, with one
|
||||
native-PC class skipped. Current-revision CI passed
|
||||
[native hosts](https://github.com/saphid/frame-control/actions/runs/36504546909),
|
||||
[installers](https://github.com/saphid/frame-control/actions/runs/36504546800)
|
||||
and [general checks](https://github.com/saphid/frame-control/actions/runs/36504546887).
|
||||
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
|
||||
| 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. |
|
||||
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
|
||||
| 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. |
|
||||
@@ -85,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
|
||||
|
||||
## OBB files
|
||||
|
||||
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
|
||||
The CLI helper below remains available for a running instance.
|
||||
|
||||
```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
|
||||
|
||||
@@ -187,17 +187,6 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## PC panel host tests
|
||||
|
||||
`tests/test_pcview.py` checks source-bound tickets, reconnect/Stop revocation,
|
||||
input release, source-coordinate mapping and use of the existing panel
|
||||
launcher. The `PC host libraries` workflow builds the bundled native adapter
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64, then runs a real x264 synthetic
|
||||
stream through the HTTP/WebSocket agent. `FRAME_PC_REQUIRE_NATIVE=1` makes a
|
||||
missing native bundle fail CI instead of skipping. These tests do not grant a
|
||||
portal dialog, capture a desktop, exercise a hardware encoder or validate
|
||||
laser alignment. See [PC host evidence](pc-in-headset.md#evidence).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
@@ -35,6 +37,8 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
@@ -100,6 +104,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
);
|
||||
@@ -111,6 +116,7 @@
|
||||
children = (
|
||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -260,6 +266,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
@@ -279,6 +286,7 @@
|
||||
files = (
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
/// Native recovery remains reachable even when WebKit cannot draw a page.
|
||||
@Published private(set) var webRecoveryMessage: String?
|
||||
@Published private(set) var webReloadID = 0
|
||||
|
||||
func webContentFailed(at url: URL, message: String) {
|
||||
guard phase == .ready(url) else { return }
|
||||
webRecoveryMessage = message
|
||||
}
|
||||
|
||||
func retryWebContent() {
|
||||
webRecoveryMessage = nil
|
||||
webReloadID += 1
|
||||
}
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
FrameSettings.parse(host: host, user: user)
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
|
||||
private func invalidate() {
|
||||
attempt += 1
|
||||
retrying = false
|
||||
webRecoveryMessage = nil
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// The persisted SSH target and its editable, lossless address form.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
|
||||
var address: String {
|
||||
let name = host.contains(":") ? "[\(host)]" : host
|
||||
return port == 22 ? name : "\(name):\(port)"
|
||||
}
|
||||
|
||||
static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var host = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = user.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var port = 22
|
||||
if host.hasPrefix("[") {
|
||||
guard let close = host.firstIndex(of: "]") else { return nil }
|
||||
let rest = host[host.index(after: close)...]
|
||||
if !rest.isEmpty {
|
||||
guard rest.hasPrefix(":"), let value = Int(rest.dropFirst()) else { return nil }
|
||||
port = value
|
||||
}
|
||||
host = String(host[host.index(after: host.startIndex)..<close])
|
||||
} else if host.filter({ $0 == ":" }).count == 1, let colon = host.lastIndex(of: ":") {
|
||||
guard let value = Int(host[host.index(after: colon)...]) else { return nil }
|
||||
port = value
|
||||
host = String(host[..<colon])
|
||||
}
|
||||
guard !host.isEmpty, !user.isEmpty, (1...65535).contains(port) else { return nil }
|
||||
return FrameSettings(host: host, port: port, user: user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each search owns its callbacks. Cancelling invalidates even probes that cannot
|
||||
/// be cancelled while Network.framework finishes opening a connection.
|
||||
struct FrameSearchState {
|
||||
private(set) var generation = UUID()
|
||||
private(set) var manualAddress: String?
|
||||
|
||||
mutating func start(manualAddress: String?) -> UUID {
|
||||
generation = UUID()
|
||||
let typed = manualAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.manualAddress = typed.isEmpty ? nil : typed
|
||||
return generation
|
||||
}
|
||||
|
||||
mutating func stop() { generation = UUID() }
|
||||
func accepts(_ generation: UUID) -> Bool { self.generation == generation }
|
||||
var discovers: Bool { manualAddress == nil }
|
||||
|
||||
static func address(manual: String, discovered: String?) -> String {
|
||||
let typed = manual.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return typed.isEmpty ? (discovered ?? "frame.local") : typed
|
||||
}
|
||||
}
|
||||
|
||||
/// One automatic recovery per session; only a deliberate retry or a new session
|
||||
/// replenishes it. A successful load alone must not create a process-crash loop.
|
||||
struct WebDocumentState {
|
||||
struct Delivery: Equatable {
|
||||
let generation: UUID
|
||||
let documentID: String
|
||||
}
|
||||
|
||||
private(set) var generation = UUID()
|
||||
private(set) var documentID: String?
|
||||
private(set) var delivering = false
|
||||
private var reloads = 0
|
||||
|
||||
mutating func beginDocument() {
|
||||
generation = UUID()
|
||||
documentID = nil
|
||||
delivering = false
|
||||
}
|
||||
|
||||
mutating func resetSession() {
|
||||
reloads = 0
|
||||
beginDocument()
|
||||
}
|
||||
|
||||
mutating func shouldReloadAfterFailure() -> Bool {
|
||||
beginDocument()
|
||||
guard reloads < 1 else { return false }
|
||||
reloads += 1
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func ready(_ id: String, generation: UUID) {
|
||||
guard self.generation == generation else { return }
|
||||
documentID = id
|
||||
}
|
||||
|
||||
mutating func beginDelivery() -> Delivery? {
|
||||
guard !delivering, let documentID else { return nil }
|
||||
delivering = true
|
||||
return Delivery(generation: generation, documentID: documentID)
|
||||
}
|
||||
|
||||
/// Only acknowledgement from the same live document may consume a link.
|
||||
mutating func finishDelivery(_ delivery: Delivery, acknowledged: Bool) -> Bool {
|
||||
guard delivery.generation == generation, delivering, delivery.documentID == documentID else { return false }
|
||||
delivering = false
|
||||
if !acknowledged { documentID = nil }
|
||||
return acknowledged
|
||||
}
|
||||
}
|
||||
|
||||
/// Small app-scoped preferences survive the tunnel's intentionally ephemeral port.
|
||||
struct FramePreferences {
|
||||
private let defaults: UserDefaults
|
||||
private static let keys = ["mvSeparate", "padUsed"]
|
||||
private static let prefix = "frame.control.preference."
|
||||
|
||||
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
func read() -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for key in Self.keys {
|
||||
if let value = defaults.object(forKey: Self.prefix + key) as? String, ["0", "1"].contains(value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func write(key: String, value: String) -> Bool {
|
||||
guard Self.keys.contains(key), ["0", "1"].contains(value) else { return false }
|
||||
defaults.set(value, forKey: Self.prefix + key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var fallback = "frame.local"
|
||||
private var search = FrameSearchState()
|
||||
private let probe: @Sendable (String) async -> Bool
|
||||
private let discoveryEnabled: Bool
|
||||
|
||||
func start(fallback: String?) {
|
||||
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
|
||||
discoveryEnabled: Bool = true) {
|
||||
self.probe = probe
|
||||
self.discoveryEnabled = discoveryEnabled
|
||||
}
|
||||
|
||||
func start(fallback: String?, manual: Bool = false) {
|
||||
stop()
|
||||
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||
let mine = search.start(manualAddress: manual ? fallback : nil)
|
||||
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
|
||||
found = nil
|
||||
since = Date()
|
||||
browse()
|
||||
if search.discovers && discoveryEnabled { browse(generation: mine) }
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let host = self.fallback
|
||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||
self.found = Found(host: host, name: host)
|
||||
guard let self, self.search.accepts(mine) else { return }
|
||||
if self.found == nil {
|
||||
let answers = await self.probe(host)
|
||||
guard !Task.isCancelled, self.search.accepts(mine) else { return }
|
||||
if answers, self.found == nil { self.found = Found(host: host, name: host) }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
search.stop()
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
private func browse(generation: UUID) {
|
||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
for result in results {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||
Self.resolve(result.endpoint) { host in
|
||||
Task { @MainActor in
|
||||
guard let self, let host else { return }
|
||||
guard let self, self.search.accepts(generation), self.search.discovers, let host else { return }
|
||||
// A found device is used over the fallback probe.
|
||||
self.found = Found(host: host, name: name)
|
||||
}
|
||||
@@ -91,12 +103,8 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||
var host = address, port: UInt16 = 22
|
||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||
host = target.host
|
||||
port = UInt16(target.port)
|
||||
}
|
||||
return await sshAnswers(host: host, port: port)
|
||||
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
|
||||
return await sshAnswers(host: target.host, port: UInt16(target.port))
|
||||
}
|
||||
|
||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||
|
||||
@@ -4,13 +4,6 @@ import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
||||
@@ -140,12 +133,6 @@ final class FrameLink: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
|
||||
@@ -10,15 +10,31 @@ struct RootView: View {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
ConnectingView(step: step, host: model.settings?.address) { model.showSetup() }
|
||||
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||
WaitingView(host: model.settings?.address ?? "", detail: message, deviceName: model.deviceName,
|
||||
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
.accessibilityHidden(model.webRecoveryMessage != nil)
|
||||
.allowsHitTesting(model.webRecoveryMessage == nil)
|
||||
if let message = model.webRecoveryMessage {
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.clockwise.circle").font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text("Reload Frame Control").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
Button("Try again") { model.retryWebContent() }
|
||||
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||
Button("Change headset") { model.showSetup() }
|
||||
}
|
||||
.padding(32).frame(maxWidth: 480)
|
||||
}
|
||||
.background(Color.frameBackground.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
@@ -33,20 +49,37 @@ extension Color {
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
/// Recovery controls remain reachable on short screens and with large text.
|
||||
private struct RecoveryLayout<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
content
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +92,23 @@ struct FailedView: View {
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,34 +121,37 @@ struct WaitingView: View {
|
||||
let reachable: () -> Void
|
||||
let change: () -> Void
|
||||
@State private var pulse = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(pulse ? 1 : 0.55)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(reduceMotion || pulse ? 1 : 0.55)
|
||||
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||
reachable()
|
||||
return
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
|
||||
reachable()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ struct SetupView: View {
|
||||
@State private var showHelp = false
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||
/// An explicit target always wins over discovery.
|
||||
private var host: String {
|
||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -34,19 +33,19 @@ struct SetupView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.frameBackground)
|
||||
.onAppear {
|
||||
manualHost = model.settings?.host ?? ""
|
||||
manualHost = model.settings?.address ?? ""
|
||||
user = model.settings?.user ?? "steamos"
|
||||
var fallback = model.settings?.host
|
||||
var fallback = model.settings?.address
|
||||
#if DEBUG
|
||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||
#endif
|
||||
finder.start(fallback: fallback)
|
||||
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
|
||||
}
|
||||
.onDisappear { finder.stop() }
|
||||
// After a few seconds of not finding it, say exactly what to check.
|
||||
.task(id: finder.since) {
|
||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
showHelp = true
|
||||
if !Task.isCancelled { showHelp = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ struct SetupView: View {
|
||||
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.onSubmit { finder.start(fallback: manualHost) }
|
||||
.onSubmit { finder.start(fallback: manualHost, manual: true) }
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("User", text: $user)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
@@ -149,7 +148,9 @@ struct SetupView: View {
|
||||
}
|
||||
.onChange(of: manualHost) { _, value in
|
||||
// A typed address replaces the search.
|
||||
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||
showHelp = false
|
||||
let typed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
finder.start(fallback: typed, manual: !typed.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,13 @@ struct WebShell: UIViewRepresentable {
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
@@ -47,16 +46,21 @@ struct WebShell: UIViewRepresentable {
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.__frameDocumentID = crypto.randomUUID();
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
preferences: {
|
||||
read: () => call("preferencesRead"),
|
||||
write: (key, value) => call("preferencesWrite", { key, value }),
|
||||
},
|
||||
notify: (message, request) => call("notify", { message, request }),
|
||||
readClipboard: () => call("readClipboard"),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -64,7 +68,18 @@ struct WebShell: UIViewRepresentable {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
private(set) var document = WebDocumentState()
|
||||
private(set) var reloadID = 0
|
||||
private var activeNavigation: WKNavigation?
|
||||
private let preferences = FramePreferences()
|
||||
|
||||
@MainActor
|
||||
func load(_ url: URL, reloadID: Int) {
|
||||
loaded = url
|
||||
self.reloadID = reloadID
|
||||
document.resetSession()
|
||||
activeNavigation = web?.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
@@ -118,6 +133,14 @@ struct WebShell: UIViewRepresentable {
|
||||
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
|
||||
}
|
||||
}
|
||||
case "preferencesRead":
|
||||
replyHandler(preferences.read(), nil)
|
||||
case "preferencesWrite":
|
||||
guard let args = arg as? [String: Any], let key = args["key"] as? String,
|
||||
let value = args["value"] as? String, preferences.write(key: key, value: value) else {
|
||||
return replyHandler(nil, "Invalid preference")
|
||||
}
|
||||
replyHandler(true, nil)
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "setUpConnection":
|
||||
@@ -135,9 +158,20 @@ struct WebShell: UIViewRepresentable {
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
guard let id = arg as? String, let web,
|
||||
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return replyHandler(nil, "Invalid document")
|
||||
}
|
||||
let generation = document.generation
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(json)") { [weak self] result, error in
|
||||
guard let self else { return replyHandler(nil, "Page closed") }
|
||||
if error == nil, result as? Bool == true {
|
||||
self.document.ready(id, generation: generation)
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
replyHandler(nil, nil)
|
||||
}
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
@@ -145,13 +179,23 @@ struct WebShell: UIViewRepresentable {
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req),
|
||||
let text = String(data: json, encoding: .utf8),
|
||||
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
|
||||
let id = String(data: idData, encoding: .utf8) else {
|
||||
_ = document.finishDelivery(delivery, acknowledged: false)
|
||||
return
|
||||
}
|
||||
// Keep the head queued until the live document confirms its callback ran.
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(id) && window.__frameInstallLink(\(text)) === true") { [weak self] result, error in
|
||||
guard let self, let loaded = self.loaded, self.model.phase == .ready(loaded),
|
||||
self.reloadID == self.model.webReloadID else { return }
|
||||
guard self.document.finishDelivery(delivery, acknowledged: error == nil && result as? Bool == true) else { return }
|
||||
if self.model.pendingInstallLinks.first == link { self.model.pendingInstallLinks.removeFirst() }
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +230,55 @@ struct WebShell: UIViewRepresentable {
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// A new document cannot receive links using the previous page's callback.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
activeNavigation = navigation
|
||||
document.beginDocument()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
recover(webView, message: "The app page stopped responding. Try loading it again, or change headset.")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func navigationFailed(_ webView: WKWebView, navigation: WKNavigation?, error: Error) {
|
||||
guard navigation === activeNavigation, (error as NSError).code != NSURLErrorCancelled else { return }
|
||||
recover(webView, message: "Couldn't load the app page. \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func recover(_ webView: WKWebView, message: String) {
|
||||
guard let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
model.webRecoveryMessage == nil else { return }
|
||||
activeNavigation = nil
|
||||
if document.shouldReloadAfterFailure() {
|
||||
// Reload the original authenticated URL, including its session key.
|
||||
activeNavigation = webView.load(URLRequest(url: loaded))
|
||||
} else {
|
||||
model.webContentFailed(at: loaded, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
#if DEBUG
|
||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
#if !NATIVE_CONTRACT_STANDALONE
|
||||
@testable import Frame_Control
|
||||
#endif
|
||||
|
||||
final class NativeSessionTests: XCTestCase {
|
||||
func testSavedAddressesRoundTripWithoutLosingPortOrIPv6() throws {
|
||||
for host in ["frame.local", "100.100.100.2", "2001:db8::42", "fe80::1%en0"] {
|
||||
for port in [22, 2222, 65535] {
|
||||
let target = FrameSettings(host: host, port: port, user: "alex")
|
||||
let saved = try JSONDecoder().decode(FrameSettings.self, from: JSONEncoder().encode(target))
|
||||
XCTAssertEqual(FrameSettings.parse(host: saved.address, user: saved.user), target)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(FrameSettings.parse(host: " [2001:db8::42]:2222\n", user: " steamos "),
|
||||
FrameSettings(host: "2001:db8::42", port: 2222))
|
||||
for host in ["", "frame.local:0", "frame.local:65536", "frame.local:no", "[::1]:no", "[::1]junk", "[::1"] {
|
||||
XCTAssertNil(FrameSettings.parse(host: host, user: "steamos"), host)
|
||||
}
|
||||
}
|
||||
|
||||
func testManualTargetWinsAndSearchCallbacksExpire() {
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " 100.100.100.2:2222 ", discovered: "192.168.1.237"), "100.100.100.2:2222")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " \n", discovered: "192.168.1.237"), "192.168.1.237")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: "", discovered: nil), "frame.local")
|
||||
var search = FrameSearchState()
|
||||
let bonjour = search.start(manualAddress: nil)
|
||||
XCTAssertTrue(search.discovers)
|
||||
let manual = search.start(manualAddress: "100.100.100.2:2222")
|
||||
XCTAssertFalse(search.discovers)
|
||||
XCTAssertFalse(search.accepts(bonjour))
|
||||
XCTAssertTrue(search.accepts(manual))
|
||||
search.stop()
|
||||
XCTAssertFalse(search.accepts(manual))
|
||||
_ = search.start(manualAddress: "")
|
||||
XCTAssertTrue(search.discovers)
|
||||
}
|
||||
|
||||
func testRecoveryIsBoundedUntilAnExplicitRetry() {
|
||||
var page = WebDocumentState()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.beginDocument()
|
||||
page.ready("reloaded", generation: page.generation)
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
page.resetSession()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
}
|
||||
|
||||
func testReplacementDocumentsCannotConsumeQueuedLinks() throws {
|
||||
var page = WebDocumentState()
|
||||
var queue = ["first", "second"]
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
let oldGeneration = page.generation
|
||||
page.ready("first document", generation: oldGeneration)
|
||||
let oldDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
XCTAssertNil(page.beginDelivery(), "Serialize delivery while an acknowledgement is pending")
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.ready("stale readiness", generation: oldGeneration)
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
if page.finishDelivery(oldDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let failedDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(failedDelivery, acknowledged: false) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
XCTAssertNil(page.beginDelivery(), "An evaluation error needs fresh readiness")
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let liveDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(liveDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["second"])
|
||||
XCTAssertNotNil(page.beginDelivery())
|
||||
page.beginDocument()
|
||||
XCTAssertNil(page.beginDelivery(), "Even healthy main-document navigation resets readiness")
|
||||
}
|
||||
|
||||
func testPreferencesPersistAcrossSessionsAndRejectOtherKeysAndValues() throws {
|
||||
let suite = "frame-control-contract-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let first = FramePreferences(defaults: defaults)
|
||||
XCTAssertEqual(first.read(), [:])
|
||||
XCTAssertTrue(first.write(key: "mvSeparate", value: "0"))
|
||||
XCTAssertTrue(first.write(key: "padUsed", value: "1"))
|
||||
let second = FramePreferences(defaults: try XCTUnwrap(UserDefaults(suiteName: suite)))
|
||||
XCTAssertEqual(second.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
for (key, value) in [("other", "1"), ("mvSeparate", "false"), ("padUsed", ""), ("padUsed", "2")] {
|
||||
XCTAssertFalse(second.write(key: key, value: value))
|
||||
}
|
||||
XCTAssertEqual(first.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
XCTAssertNil(defaults.object(forKey: "frame.control.preference.other"))
|
||||
defaults.set("bad", forKey: "frame.control.preference.mvSeparate")
|
||||
defaults.set("1", forKey: "frame.control.preference.unknown")
|
||||
XCTAssertEqual(second.read(), ["padUsed": "1"])
|
||||
defaults.set(true, forKey: "frame.control.preference.padUsed")
|
||||
XCTAssertEqual(second.read(), [:], "Only stored strings are supported")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", "frame/android/*.js",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json", "frame/openxr-compat/prebuilt/arm64-v8a/*.so.gz",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
@@ -26,5 +26,3 @@
|
||||
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
|
||||
- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
|
||||
@end
|
||||
|
||||
#include "../../../desktop/controller.h"
|
||||
@@ -1,45 +1,234 @@
|
||||
// Thin, serialized binding to the same controller used by the PC host.
|
||||
// Algorithm and bounded state live in desktop/controller.c.
|
||||
// Adapts each stream to its network, latency first. The viewer acknowledges
|
||||
// every frame as it arrives ("rx"); from those acks the controller knows how
|
||||
// long frames take to get through and how fast the link delivers them.
|
||||
//
|
||||
// - The gate: a new frame is sent only while the oldest unacknowledged one is
|
||||
// younger than the path's usual round trip plus a little slack. So frames
|
||||
// never queue up in SSH, TCP or the Wi-Fi driver; while the link is stuck
|
||||
// the newest picture waits and goes out as soon as it moves again.
|
||||
// - The bitrate: when frames start queueing (the round trip grows) or the
|
||||
// gate has to hold frames back, it drops to a bit under what the link
|
||||
// actually delivered; once things are clear it probes up again slowly, never
|
||||
// above the quality setting's bitrate (the ceiling).
|
||||
// - The tier: as the bitrate falls, fewer frames per second (60, 45, 30), then
|
||||
// a smaller picture (75%, then 50% of the panel's pixels).
|
||||
// See docs/mac-in-headset.md ("Adapting to the network"). Thread-safe.
|
||||
import Foundation
|
||||
|
||||
final class RateController {
|
||||
struct Tier: Equatable {
|
||||
let fps: Int
|
||||
let scale: Double // of the picture's long side
|
||||
}
|
||||
|
||||
static let tiers = [Tier(fps: 60, scale: 1), Tier(fps: 45, scale: 1), Tier(fps: 30, scale: 1),
|
||||
Tier(fps: 30, scale: 0.75), Tier(fps: 30, scale: 0.5)]
|
||||
/// A tier is used while the target bitrate is at least this share of the ceiling.
|
||||
static let floors = [0.45, 0.28, 0.16, 0.08, 0]
|
||||
static let enabled = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ADAPT"] != "0"
|
||||
|
||||
let maxFps: Int
|
||||
private let lock = NSLock()
|
||||
private let core: OpaquePointer
|
||||
private var events: [[String: Any]] = []
|
||||
init(maxFps: Int) {
|
||||
self.maxFps = maxFps
|
||||
core = fc_new(Int32(maxFps), Self.enabled ? 1 : 0)!
|
||||
}
|
||||
deinit { fc_free(core) }
|
||||
private var ceiling = 0 // bits/s at full size and frame rate
|
||||
private(set) var target = 0
|
||||
private(set) var tier = 0
|
||||
private var unacked: [(seq: UInt32, sent: Int64, bytes: Int)] = []
|
||||
/// Round trips (send -> ack arrives here), for the baseline: the lowest
|
||||
/// in the last 10 s is the path without any queue.
|
||||
private var rtts: [(t: Int64, v: Int64)] = []
|
||||
private var acked: [(t: Int64, bytes: Int)] = [] // the last second
|
||||
private var sentLog: [(t: Int64, bytes: Int)] = []
|
||||
private var captures: [Int64] = [] // the last second
|
||||
private var frameBytes = 0 // average recent frame, kept while the gate holds everything back
|
||||
private var held = 0 // frames the gate held back since the last update
|
||||
private var lastSignal = false
|
||||
private var sawAck = false
|
||||
private var lastDecrease: Int64 = 0
|
||||
private var lastIncrease: Int64 = 0
|
||||
private var belowSince: Int64 = 0, aboveSince: Int64 = 0
|
||||
/// What changed, for the timeline: (time, event).
|
||||
private(set) var events: [(Int64, String)] = []
|
||||
|
||||
init(maxFps: Int) { self.maxFps = maxFps }
|
||||
|
||||
private func locked<T>(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
|
||||
private func value(_ field: Int32) -> Int { Int(fc_value(core, field)) }
|
||||
var target: Int { locked { value(0) } }
|
||||
var tier: Int { locked { value(2) } }
|
||||
var fps: Int { locked { value(3) } }
|
||||
var scale: Double { locked { Double(value(4)) / 100 } }
|
||||
var baseRtt: Int64 { locked { fc_value(core, 5) } }
|
||||
func setCeiling(_ bps: Int) { locked { fc_ceiling(core, Int32(bps)) } }
|
||||
func maySend(now: Int64, counts: Bool = true) -> Bool { locked { fc_gate(core, now, counts ? 1 : 0) != 0 } }
|
||||
func captured(at t: Int64) { locked { fc_capture(core, t) } }
|
||||
func sent(seq: UInt32, bytes: Int, at t: Int64) { locked { fc_sent(core, seq, Int32(bytes), t) } }
|
||||
func acked(seq: UInt32, at t: Int64) -> Bool { locked { fc_ack(core, seq, t) != 0 } }
|
||||
func update(now: Int64) -> Int? {
|
||||
|
||||
/// The quality setting's bitrate at full size; the first call also starts there.
|
||||
func setCeiling(_ bps: Int) {
|
||||
locked {
|
||||
let old = value(0), tier = value(2)
|
||||
let result = Int(fc_update(core, now))
|
||||
if value(0) < old || value(2) != tier {
|
||||
events.append(["t": now, "e": "target \(value(0)) bit/s; tier \(value(2))"])
|
||||
if events.count > 200 { events.removeFirst() }
|
||||
}
|
||||
return result == 0 ? nil : result
|
||||
if target == 0 || target > bps { target = bps }
|
||||
ceiling = bps
|
||||
}
|
||||
}
|
||||
func state() -> [String: Any] {
|
||||
locked { ["target": value(0), "ceiling": value(1), "tier": value(2), "fps": value(3),
|
||||
"scale": Double(value(4)) / 100, "baseRtt": Double(value(5)) / 1000,
|
||||
"inFlight": value(6), "slack": Double(value(7)) / 1000, "adapt": Self.enabled] }
|
||||
|
||||
var fps: Int { locked { fpsLocked } }
|
||||
private var fpsLocked: Int { min(maxFps, RateController.tiers[tier].fps) }
|
||||
var scale: Double { locked { RateController.tiers[tier].scale } }
|
||||
var baseRtt: Int64 { locked { baseline() } }
|
||||
|
||||
private func baseline() -> Int64 { rtts.map(\.v).min() ?? 0 }
|
||||
|
||||
/// How late a frame may be before the gate holds the next one: one frame
|
||||
/// interval, plus room for the jitter this link normally has (1.5 times
|
||||
/// its recent spread), so ordinary Wi-Fi jitter doesn't cost frames but a
|
||||
/// real queue does. Updated in update().
|
||||
private var slack: Int64 = 40_000
|
||||
|
||||
/// Whether a frame may be sent now without queueing behind earlier ones.
|
||||
/// `counts`: a held frame is a sign of congestion (not when merely
|
||||
/// re-checking whether a held frame can go yet).
|
||||
func maySend(now: Int64, counts: Bool = true) -> Bool {
|
||||
locked {
|
||||
guard RateController.enabled, sawAck else { return true } // not heard from the viewer yet
|
||||
// Unacknowledged for 2 s: gone with a reconnection, not in a queue.
|
||||
unacked.removeAll { now - $0.sent > 2_000_000 }
|
||||
guard let oldest = unacked.first else { return true }
|
||||
// Age is what bounds latency. The count only stops a burst, and it
|
||||
// allows a full round trip of frames, so a long but clear path
|
||||
// (100 ms away) still gets every frame.
|
||||
let interval = Int64(1_000_000 / max(1, fpsLocked))
|
||||
let window = max(3, Int((baseline() + slack) / interval) + 1)
|
||||
if unacked.count < window, now - oldest.sent <= baseline() + slack { return true }
|
||||
if counts { held += 1 }
|
||||
return false
|
||||
}
|
||||
}
|
||||
func eventList() -> [[String: Any]] { locked { events } }
|
||||
|
||||
/// A picture was captured (sent or not): with the frame sizes, what this
|
||||
/// stream would send if the link allowed.
|
||||
func captured(at t: Int64) {
|
||||
locked {
|
||||
captures.append(t)
|
||||
if captures.count > 256 { captures.removeFirst(captures.count - 256) }
|
||||
}
|
||||
}
|
||||
|
||||
func sent(seq: UInt32, bytes: Int, at t: Int64) {
|
||||
locked {
|
||||
// Bounded even if the viewer never acknowledges (an old viewer, or
|
||||
// the controller is off).
|
||||
unacked.append((seq, t, bytes))
|
||||
if unacked.count > 512 { unacked.removeFirst(unacked.count - 512) }
|
||||
sentLog.append((t, bytes))
|
||||
if sentLog.count > 1024 { sentLog.removeFirst(sentLog.count - 1024) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer has frame `seq`. Returns true if that may let a held frame go.
|
||||
func acked(seq: UInt32, at now: Int64) -> Bool {
|
||||
locked {
|
||||
sawAck = true
|
||||
guard let i = unacked.firstIndex(where: { $0.seq == seq }) else { return false }
|
||||
let f = unacked[i]
|
||||
unacked.removeSubrange(0...i) // TCP delivers in order: earlier ones arrived too
|
||||
rtts.append((now, now - f.sent))
|
||||
acked.append((now, f.bytes))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every 100 ms. Returns the new bitrate target, or nil if the
|
||||
/// controller is off.
|
||||
func update(now: Int64) -> Int? {
|
||||
locked {
|
||||
rtts.removeAll { now - $0.t > 10_000_000 }
|
||||
acked.removeAll { now - $0.t > 500_000 }
|
||||
sentLog.removeAll { now - $0.t > 500_000 }
|
||||
unacked.removeAll { now - $0.sent > 2_000_000 }
|
||||
captures.removeAll { now - $0 > 1_000_000 }
|
||||
guard RateController.enabled, ceiling > 0 else { return nil }
|
||||
let base = baseline()
|
||||
let spread = rtts.filter { now - $0.t < 2_000_000 }.map(\.v).sorted()
|
||||
let jitter = spread.isEmpty ? 0 : spread[spread.count * 9 / 10] - base
|
||||
let interval = Int64(1_000_000 / max(1, fpsLocked))
|
||||
slack = interval + min(max(jitter * 3 / 2, 25_000), 80_000)
|
||||
let recent = rtts.filter { now - $0.t < 300_000 }.map(\.v).sorted()
|
||||
let queueing = recent.isEmpty ? 0 : recent[recent.count / 2] - base
|
||||
let oldestAge = unacked.first.map { now - $0.sent } ?? 0
|
||||
let stuck = oldestAge > base + 100_000
|
||||
let delivered = acked.reduce(0) { $0 + $1.bytes } * 16 // bits/s over the last half second
|
||||
let sending = sentLog.reduce(0) { $0 + $1.bytes } * 16
|
||||
// Demand: captures per second (up to the tier's rate) times the
|
||||
// average frame. A test card or a mostly still window wants far
|
||||
// less than its budget; when the link hiccups, cutting its bitrate
|
||||
// can't help, and it would only look link-limited afterwards.
|
||||
if !sentLog.isEmpty { frameBytes = sentLog.reduce(0) { $0 + $1.bytes } / sentLog.count }
|
||||
let demand = min(captures.count, fpsLocked) * frameBytes * 8
|
||||
// Delay alone isn't our queue: Wi-Fi jitters by itself. It only
|
||||
// counts while this stream uses a good part of its budget (so its
|
||||
// own data could be what's queueing). Frames the gate had to hold,
|
||||
// or one stuck in flight, show demand the link isn't carrying
|
||||
// whatever was sent (the gate itself keeps what's sent low).
|
||||
let busy = sending >= target / 2
|
||||
// Twice in a row (200 ms), so one late ack doesn't count.
|
||||
let signal = (busy && queueing > 40_000) || held >= 3 || stuck
|
||||
let congested = signal && lastSignal
|
||||
lastSignal = signal
|
||||
let heldNow = held
|
||||
held = 0
|
||||
let floorBps = 300_000
|
||||
if congested, now - lastDecrease > 300_000 {
|
||||
// Down to a bit under what got through: at least a fifth off, at
|
||||
// most half (a stall delivers nothing, but the link is still there).
|
||||
let measured = Int(Double(delivered) * 0.9)
|
||||
var next = max(floorBps, min(target * 4 / 5, max(measured, target / 2)))
|
||||
// App-limited (it wants about half its budget or less): never
|
||||
// below twice what it wants, however many cuts in a row. The
|
||||
// extra quarter is hysteresis, so frame sizes wobbling at the
|
||||
// floor don't switch the protection off.
|
||||
if demand > 0, demand * 2 <= target * 5 / 4 { next = max(next, min(target, demand * 2)) }
|
||||
target = next
|
||||
lastDecrease = now
|
||||
events.append((now, "down to \(target / 1000) kbit/s: queue \(queueing / 1000) ms, held \(heldNow), "
|
||||
+ "oldest \(oldestAge / 1000) ms, base \(base / 1000) ms, sent \(sending / 1000) got \(delivered / 1000) "
|
||||
+ "wants \(demand / 1000)"))
|
||||
} else if !congested, now - lastDecrease > 1_000_000, now - lastIncrease > 250_000, target < ceiling,
|
||||
sending > target * 6 / 10 || now - lastDecrease > 3_000_000 {
|
||||
// Clear for a second and using what it has: probe up.
|
||||
target = min(ceiling, Int(Double(target) * 1.1) + 50_000)
|
||||
lastIncrease = now
|
||||
}
|
||||
// Fewer frames or pixels only help a stream that fills its budget;
|
||||
// a small one (a still window, a test card) keeps its tier.
|
||||
retier(now: now, linkLimited: sending >= target * 7 / 10)
|
||||
if events.count > 200 { events.removeFirst(events.count - 200) }
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps down quickly, straight to the tier the bitrate supports, and back
|
||||
/// up one tier at a time only when there's clearly room (hysteresis).
|
||||
private func retier(now: Int64, linkLimited: Bool) {
|
||||
let share = Double(target) / Double(max(ceiling, 1))
|
||||
if tier < RateController.tiers.count - 1, share < RateController.floors[tier], linkLimited {
|
||||
if belowSince == 0 { belowSince = now }
|
||||
if now - belowSince > 500_000 {
|
||||
tier = RateController.floors.firstIndex { share >= $0 } ?? RateController.tiers.count - 1
|
||||
belowSince = 0
|
||||
events.append((now, "tier \(tier)"))
|
||||
}
|
||||
} else {
|
||||
belowSince = 0
|
||||
}
|
||||
if tier > 0, share > RateController.floors[tier - 1] * 1.25 {
|
||||
if aboveSince == 0 { aboveSince = now }
|
||||
if now - aboveSince > 2_000_000 {
|
||||
tier -= 1
|
||||
aboveSince = 0
|
||||
events.append((now, "tier \(tier)"))
|
||||
}
|
||||
} else {
|
||||
aboveSince = 0
|
||||
}
|
||||
}
|
||||
|
||||
func state() -> [String: Any] {
|
||||
locked {
|
||||
["target": target, "ceiling": ceiling, "tier": tier, "fps": min(maxFps, RateController.tiers[tier].fps),
|
||||
"scale": RateController.tiers[tier].scale, "baseRtt": Double(baseline()) / 1000,
|
||||
"inFlight": unacked.count, "slack": Double(slack) / 1000, "adapt": RateController.enabled]
|
||||
}
|
||||
}
|
||||
|
||||
func eventList() -> [[String: Any]] { locked { events.map { ["t": $0.0, "e": $0.1] } } }
|
||||
}
|
||||
@@ -5,10 +5,7 @@ set -eu
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=${1:-$here/../bin/frame-mac-view}
|
||||
mkdir -p "$(dirname "$out")"
|
||||
obj=$(mktemp /tmp/frame-controller.XXXXXX)
|
||||
trap 'rm -f "$obj"' EXIT
|
||||
xcrun clang -O2 -target arm64-apple-macos14.0 -c "$here/../../desktop/controller.c" -o "$obj"
|
||||
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
|
||||
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
|
||||
-o "$out" "$here"/Sources/*.swift "$obj"
|
||||
-o "$out" "$here"/Sources/*.swift
|
||||
echo "built $out"
|
||||
+13
-55
@@ -55,7 +55,6 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
|
||||
RESULTS = ROOT / "bench" / "results"
|
||||
PAGES = ROOT / "bench" / "pages"
|
||||
@@ -255,10 +254,7 @@ class LabView(frame_macview.MacView):
|
||||
|
||||
def ssh_runner(base):
|
||||
def run(remote, stdin=None, timeout=30):
|
||||
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
|
||||
capture_output=True, timeout=timeout)
|
||||
r.stdout = r.stdout.decode("utf-8", errors="replace")
|
||||
r.stderr = r.stderr.decode("utf-8", errors="replace")
|
||||
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
if r.returncode:
|
||||
e = RuntimeError((r.stderr or r.stdout).strip())
|
||||
e.stdout = r.stdout
|
||||
@@ -385,11 +381,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if args.browser_flag is not None:
|
||||
mv.browser_flags = [f for f in args.browser_flag if f]
|
||||
chrome = None
|
||||
src = args.source or "test"
|
||||
cpu = None
|
||||
if args.pc:
|
||||
mv.host = "windows" if sys.platform == "win32" else "linux"
|
||||
mv.viewer_profile = "pc-view"
|
||||
src = "test"
|
||||
try:
|
||||
if scenario in ("scroll", "type"):
|
||||
chrome = chrome_window(f"{scenario}.html")
|
||||
@@ -413,10 +405,9 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
since = max([f["s"] for f in first["frames"]] or [0])
|
||||
captured_before = first["captured"]
|
||||
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
|
||||
if not args.pc:
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
if relay:
|
||||
relay.begin()
|
||||
expected = 0 # input events the harness asked the viewer for
|
||||
@@ -452,8 +443,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if not streams:
|
||||
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
|
||||
data = streams[0]
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
cpu.stop()
|
||||
if args.raw:
|
||||
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
|
||||
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
|
||||
@@ -462,7 +452,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
for e in data.get("events", []) if e["t"] >= start_mac_us]
|
||||
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
|
||||
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
|
||||
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
|
||||
result["cpu"] = cpu.summary()
|
||||
result["viewer"] = data["summary"].get("decoder", "")
|
||||
result["show_s"] = show_s
|
||||
result["panel"] = shown.get("panel")
|
||||
@@ -470,8 +460,6 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
result["route"] = mv.route
|
||||
return result
|
||||
finally:
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
try:
|
||||
mv.stop(src)
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
@@ -682,8 +670,6 @@ def fmt(v):
|
||||
|
||||
def add_run_args(r):
|
||||
r.add_argument("--scenario", default="test,scroll,type")
|
||||
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
|
||||
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
|
||||
r.add_argument("--duration", type=float, default=20)
|
||||
r.add_argument("--warmup", type=float, default=3)
|
||||
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
|
||||
@@ -731,7 +717,7 @@ def frame_ssh_for(args):
|
||||
|
||||
|
||||
def run_suite(args, frame_ssh, quiet=False):
|
||||
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
|
||||
agent_port, token = lab_agent()
|
||||
results = []
|
||||
for sc in args.scenario.split(","):
|
||||
if not quiet:
|
||||
@@ -758,12 +744,11 @@ def document(args, frame_ssh, results, **extra):
|
||||
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
|
||||
return {
|
||||
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
|
||||
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
|
||||
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
|
||||
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
|
||||
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
|
||||
"scenarios": results, **extra,
|
||||
}
|
||||
|
||||
@@ -834,46 +819,19 @@ def main():
|
||||
print("\nworse:\n " + "\n ".join(regs))
|
||||
sys.exit(1 if regs else 0)
|
||||
|
||||
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
|
||||
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
|
||||
if args.pc and args.scenario == "capture" and not args.source:
|
||||
p.error("capture needs --source window:ID, display:ID, or choose")
|
||||
if not args.pc:
|
||||
lab_agent()
|
||||
lab_agent()
|
||||
frame_ssh = frame_ssh_for(args)
|
||||
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
|
||||
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
|
||||
args.pc_view = None
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
|
||||
try:
|
||||
if args.pc:
|
||||
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
|
||||
args.pc_view.ensure_agent()
|
||||
if args.source == "choose":
|
||||
args.pc_view.call("/permissions", method="POST")
|
||||
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
|
||||
deadline = time.monotonic()+125
|
||||
while time.monotonic() < deadline:
|
||||
state = args.pc_view.call("/status")
|
||||
if not state.get("selecting"):
|
||||
sources = args.pc_view.call("/windows")["windows"]
|
||||
if not sources:
|
||||
raise SystemExit(state.get("selectionError") or "Nothing was shared")
|
||||
args.source = sources[-1]["src"]
|
||||
break
|
||||
time.sleep(.5)
|
||||
else:
|
||||
raise SystemExit("Sharing dialog timed out")
|
||||
if args.cmd == "ab":
|
||||
arm_runs, table = ab(args, frame_ssh)
|
||||
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
|
||||
return
|
||||
results = run_suite(args, frame_ssh)
|
||||
finally:
|
||||
if args.pc_view:
|
||||
args.pc_view.shutdown()
|
||||
if awake:
|
||||
awake.terminate()
|
||||
awake.terminate()
|
||||
doc = document(args, frame_ssh, results)
|
||||
write(doc, args)
|
||||
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
|
||||
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
|
||||
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
|
||||
# panel on the Steam Frame, so you can browse and install apps from inside.
|
||||
# See docs/apks.md → "The store in the headset".
|
||||
#
|
||||
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
|
||||
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
|
||||
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
|
||||
# needed after `open`.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
# scripts/store-on-frame.sh close # close the panel and stop the server
|
||||
# scripts/store-on-frame.sh status
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
HERE=${0:A:h}
|
||||
ROOT=${HERE:h}
|
||||
PORT=47812 # the Frame's 127.0.0.1 only
|
||||
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
|
||||
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
|
||||
REMOTE=.local/share/frame-control/store-server
|
||||
|
||||
cmd=${1:-}
|
||||
|
||||
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
|
||||
# server on the Frame has exactly the files the desktop app ships.
|
||||
deploy() {
|
||||
local tmp version
|
||||
tmp=$(mktemp -d)
|
||||
trap "rm -rf ${(q)tmp}" EXIT
|
||||
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
|
||||
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
|
||||
print -u2 "==> Copying Frame Control ($version) to the Frame"
|
||||
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
|
||||
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
|
||||
fi
|
||||
# Older versions, unless a server is still running from one.
|
||||
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
|
||||
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
|
||||
print -r -- "$version"
|
||||
}
|
||||
|
||||
# Runs on the Frame. Args: dir port appid key
|
||||
OPEN=$(cat <<'EOF'
|
||||
set -u
|
||||
dir=$HOME/$1 port=$2 appid=$3 key=$4
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
|
||||
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
|
||||
else
|
||||
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
|
||||
exit 3
|
||||
fi
|
||||
# A fresh server each time, so it runs this version with this key.
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
|
||||
# Match the browser by its profile flag and the server by its script path: this
|
||||
# launcher's own command line holds the bundle path, so a looser pattern kills it.
|
||||
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
|
||||
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
|
||||
sleep 0.5
|
||||
cd "$dir" || exit 1
|
||||
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
|
||||
>/tmp/frame-store-server.log 2>&1 </dev/null &
|
||||
echo $! > /tmp/frame-store-server.pid
|
||||
up=
|
||||
for _ in $(seq 1 40); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
|
||||
sleep 0.25
|
||||
done
|
||||
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
|
||||
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
|
||||
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
|
||||
--disable-features=Translate,MediaRouter --window-size=1280,800 \
|
||||
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
|
||||
# Tag the window so it's its own panel (docs/panels.md).
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
|
||||
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
|
||||
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
|
||||
echo "panel valve.steam.desktopgame.$appid window $win"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "Chromium started, but its window didn't appear within 30 s:" >&2
|
||||
tail -n 15 /tmp/frame-store.log >&2
|
||||
exit 1
|
||||
EOF
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
open)
|
||||
version=$(deploy | tail -n 1)
|
||||
key=$(openssl rand -hex 16)
|
||||
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
|
||||
;;
|
||||
close)
|
||||
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
|
||||
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
|
||||
;;
|
||||
status)
|
||||
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
|
||||
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
|
||||
;;
|
||||
*) sed -n '2,15p' "$0"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,72 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
|
||||
|
||||
async function session(native, initial = {}, duringRead) {
|
||||
const saved = { ...initial }, element = { checked: true, disabled: false };
|
||||
const storage = { getItem: key => saved[key] ?? null, setItem: (key, value) => { saved[key] = String(value); } };
|
||||
for (const key of ['mvSeparate', 'padUsed']) Object.defineProperty(storage, key, {
|
||||
get: () => saved[key], set: value => { saved[key] = String(value); },
|
||||
});
|
||||
const calls = [];
|
||||
const context = vm.createContext({ window: { frameApp: native ? { preferences: native } : undefined }, localStorage: storage,
|
||||
$: () => element, log: (...args) => calls.push(args),
|
||||
api: async () => ({ state: 'off' }), padStart: () => calls.push('start'), padShow: s => calls.push(s.state) });
|
||||
const marker = html.indexOf('// ---- client preferences');
|
||||
const start = marker >= 0 ? marker : html.indexOf('$("mvSeparate").checked =');
|
||||
vm.runInContext(html.slice(start, html.indexOf('$("macview").onclick', start)), context);
|
||||
if (duringRead) duringRead(context, element);
|
||||
await vm.runInContext('typeof clientPreferences === "undefined" ? Promise.resolve() : clientPreferences.ready', context);
|
||||
return { element, saved, calls, context, async change(value) {
|
||||
element.checked = value; element.onchange({ target: element });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
} };
|
||||
}
|
||||
|
||||
test('a layout choice survives moving from one native local origin to another', async () => {
|
||||
const values = {};
|
||||
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
|
||||
const first = await session(native);
|
||||
await first.change(false);
|
||||
assert.equal(first.element.checked, false);
|
||||
const second = await session(native); // fresh origin storage
|
||||
assert.equal(second.element.checked, false);
|
||||
});
|
||||
|
||||
test('native values win over stale origin values and missing values migrate', async () => {
|
||||
const values = { mvSeparate: '0' };
|
||||
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
|
||||
const page = await session(native, { mvSeparate: '1', padUsed: '1' });
|
||||
assert.equal(page.element.checked, false);
|
||||
assert.equal(values.padUsed, '1');
|
||||
});
|
||||
|
||||
test('ordinary browser storage and a failed native read retain usable choices', async () => {
|
||||
assert.equal((await session(null, { mvSeparate: '0' })).element.checked, false);
|
||||
const native = { read: async () => { throw Error('Unavailable'); }, write: async () => true };
|
||||
const page = await session(native, { mvSeparate: '0' });
|
||||
assert.equal(page.element.checked, false);
|
||||
assert.equal(page.element.disabled, false);
|
||||
});
|
||||
|
||||
test('starting input during a native read wins over its stale remembered value', async () => {
|
||||
let resolveRead;
|
||||
const writes = [];
|
||||
const native = { read: () => new Promise(resolve => { resolveRead = resolve; }), write: async (key,value) => writes.push({key,value}) };
|
||||
const page = await session(native, {}, context => {
|
||||
vm.runInContext("clientPreferences.set('padUsed','1')",context);
|
||||
resolveRead({padUsed:'0'});
|
||||
});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
|
||||
assert.deepEqual(writes,[{key:'padUsed',value:'1'}]);
|
||||
});
|
||||
|
||||
test('one failed migration does not hide another native preference', async () => {
|
||||
const native = {read:async()=>({padUsed:'1'}),write:async()=>{throw Error('disk unavailable');}};
|
||||
const page=await session(native,{mvSeparate:'0'});
|
||||
assert.equal(page.element.checked,false);
|
||||
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
|
||||
});
|
||||
+12
-33
@@ -14,7 +14,7 @@ function fixture() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},close(){this.open=false;},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
@@ -42,7 +42,7 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
@@ -64,11 +64,12 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
padReset(){},ctrlReset(){},resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
|
||||
const androidPending = new Set();`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
@@ -438,35 +439,13 @@ test('mounted filters show empty state, counts, reset and host-dependent feature
|
||||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||
});
|
||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
test('every Control Center workflow reaches its intentional workspace', () => {
|
||||
const {workspaceFixture}=require('./workspace_fixture.cjs');
|
||||
const f=workspaceFixture();
|
||||
const expected={view:'capture',shots:'capture',vrTools:'tools',comfort:'tools',pad:'tools',library:'games',getgames:'games',sideloaded:'games',android:'apps',display:'settings',androidLibrary:'apps',appStore:'apps',power:'settings',transfer:'tools',media:'tools',linuxApps:'apps',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||
assert.equal(activePage,expected[id],href);
|
||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
|
||||
assert.equal(f.run('PAGES.length'),4);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360
|
||||
const sent = [];
|
||||
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
||||
const env = {
|
||||
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
devGen: 0, ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
|
||||
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
|
||||
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
||||
|
||||
@@ -8,7 +8,7 @@ const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
function page(api) {
|
||||
const pad = { state: "ready", queue: [], sending: false };
|
||||
const shown = [];
|
||||
const fns = new Function("pad", "api", "padShow", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; });
|
||||
const fns = new Function("pad", "api", "padShow", "devGen", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; }, 0);
|
||||
return { pad, shown, ...fns };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const code = names.map(grab).join("");
|
||||
|
||||
// A fake page: elements by id. The install panel's elements exist only while
|
||||
// #detailBody holds the real app page, as in the browser.
|
||||
function page(api = async () => ({})) {
|
||||
function page(api = async () => ({}), androidApps = []) {
|
||||
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
|
||||
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
|
||||
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
|
||||
@@ -34,10 +34,10 @@ function page(api = async () => ({})) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } });
|
||||
{ open() { fail("an in-app installation opened an external page"); } }, 0);
|
||||
return { els, sourceState, calls, ...fns };
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
if (html.includes('role="alert"')) fail("a good listing showed the error state");
|
||||
}
|
||||
|
||||
// A catalogue listing that is already installed offers its management page.
|
||||
{
|
||||
const p = page(undefined, [{ package: offer.package }]);
|
||||
p.sourceState.detail = { app: { name: offer.name, offers: [{ ...offer }] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== true) fail("an installed listing reported a render failure");
|
||||
if (p.els.detailInstall.textContent !== "View installed app") fail("installed listing offered another install");
|
||||
}
|
||||
|
||||
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
||||
{
|
||||
const p = page();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Production standalone scripts with deterministic DOM/network boundaries. No browser/headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = process.env.STANDALONE_SOURCE_ROOT || path.resolve(__dirname, '..');
|
||||
const read = name => fs.readFileSync(path.join(root, 'ui', name), 'utf8');
|
||||
const script = html => html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
const tick = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
|
||||
|
||||
function fixture(html) {
|
||||
const elements = new Map(), events = new Map(), requests = [];
|
||||
const document = {
|
||||
activeElement: null,
|
||||
getElementById: id => element(id),
|
||||
querySelector: selector => selector === 'main' ? element('main') : document.querySelectorAll(selector)[0],
|
||||
querySelectorAll: selector => [...elements.values()].flatMap(el => el.children).filter(el => el.matches(selector)),
|
||||
addEventListener: (name, fn) => { if (!events.has(name)) events.set(name, []); events.get(name).push(fn); },
|
||||
};
|
||||
class Element {
|
||||
constructor(id, attrs = '', text = '') {
|
||||
this.id = id; this.dataset = {}; this.children = []; this.hidden = false; this.open = false;
|
||||
this.isConnected = true; this.value = ''; this.checked = false; this.disabled = /\bdisabled\b/.test(attrs);
|
||||
this.textContent = text; this.attrs = {}; this.listeners = new Map();
|
||||
for (const [, key, value] of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) {
|
||||
this.attrs[key] = value;
|
||||
if (key.startsWith('data-')) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
||||
}
|
||||
for (const [, key] of attrs.matchAll(/\bdata-([\w-]+)(?=\s|$)/g)) if (!Object.hasOwn(this.dataset, key)) this.dataset[key] = '';
|
||||
}
|
||||
matches(s) {
|
||||
if (s === 'button:not(:disabled)') return this.tag === 'button' && !this.disabled;
|
||||
if (s.startsWith('#')) return this.id === s.slice(1);
|
||||
const data = s.match(/\[data-([\w-]+)(?:="([^"]*)")?\]/);
|
||||
if (data) return Object.hasOwn(this.dataset, data[1]) && (data[2] === undefined || this.dataset[data[1]] === data[2]);
|
||||
return false;
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this.html = value;
|
||||
for (const child of this.children) { child.isConnected = false; if (document.activeElement === child) document.activeElement = null; }
|
||||
this.children = [...value.matchAll(/<(button|h2)\b([^>]*)>([\s\S]*?)<\/\1>/g)].map(([, tag, attrs, text]) => {
|
||||
const child = new Element((attrs.match(/\bid="([^"]+)"/) || [])[1], attrs, text);
|
||||
child.tag = tag; child.parent = this; return child;
|
||||
});
|
||||
}
|
||||
get innerHTML() { return this.html || ''; }
|
||||
querySelectorAll(s) { return this.children.filter(el => el.matches(s)); }
|
||||
querySelector(s) { return this.querySelectorAll(s)[0] || null; }
|
||||
contains(el) { return el === this || this.children.includes(el); }
|
||||
closest(s) { return this.matches(s) ? this : this.parent?.closest(s) || null; }
|
||||
setAttribute(k, v) { this.attrs[k] = v; }
|
||||
append(el) { el.parent = this; }
|
||||
focus() { if (!this.disabled && this.isConnected) document.activeElement = this; }
|
||||
showModal() { this.open = true; this.modalOpened = true; }
|
||||
close() { this.open = false; this.onclose?.(); }
|
||||
addEventListener(name, fn) { this.listeners.set(name, fn); }
|
||||
reset() {}
|
||||
}
|
||||
function element(id) {
|
||||
const dynamic = [...elements.values()].flatMap(el => el.children).find(el => el.id === id);
|
||||
if (dynamic) return dynamic;
|
||||
if (!elements.has(id)) elements.set(id, new Element(id));
|
||||
return elements.get(id);
|
||||
}
|
||||
element('detail').hidden = element('kbd').hidden = true;
|
||||
document.body = element('body');
|
||||
// The sheet is stable; its contents are replaced by the production redraw.
|
||||
element('detail').contains = el => el === element('detail') || element('sheet').contains(el);
|
||||
const context = {document, location: {search: '', hash: ''}, URLSearchParams,
|
||||
window: {addEventListener: (name, fn) => events.set(name, [fn])},
|
||||
setTimeout: () => 1, clearTimeout() {}, Event: class {},
|
||||
fetch: (url, options) => new Promise((resolve, reject) => requests.push({url, options, resolve, reject})),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(script(html).replace('__FRAME_KEY__', JSON.stringify('ios +&?key/#')), context);
|
||||
const run = code => vm.runInContext(code, context);
|
||||
const answer = async (index, data, ok = true) => { (typeof index === 'number' ? requests[index] : index).resolve({ok, json: async () => data}); await tick(); };
|
||||
return {element, document, context, requests, run, answer, events};
|
||||
}
|
||||
|
||||
test('Assistant Back encodes the injected key for the same-origin workspace only', () => {
|
||||
const html = read('assistant.html'), f = fixture(html);
|
||||
const back = f.element('back');
|
||||
const fallback = html.match(/<a[^>]*href="([^"]*)"[^>]*>Back to Frame Control/)[1];
|
||||
const destination = new URL(back.href || fallback, 'http://127.0.0.1:8123/assistant#confirm=private');
|
||||
assert.equal(destination.origin, 'http://127.0.0.1:8123');
|
||||
assert.equal(destination.pathname, '/');
|
||||
assert.equal(destination.hash, '');
|
||||
assert.equal(destination.searchParams.get('key'), 'ios +&?key/#');
|
||||
const index = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
|
||||
const expression = index.match(/const UI_KEY = ([^;]+);/)[1];
|
||||
assert.equal(vm.runInNewContext(expression, {URLSearchParams, location: {search: destination.search}}), 'ios +&?key/#');
|
||||
for (const [, href] of html.matchAll(/href="(https?:[^\"]+)"/g)) assert.ok(!href.includes('key='));
|
||||
assert.match(html, /referrerpolicy="no-referrer"/);
|
||||
});
|
||||
|
||||
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.view = "installed"; render()');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const retry = f.element('content').querySelector('[data-retry-installed]');
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.requests[1].url, '/api/android');
|
||||
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
const failed = f.run('loadInstalled()');
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
f.requests[2].reject(Error('refresh failed')); await failed;
|
||||
assert.match(f.element('content').innerHTML, /Cached app/);
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
|
||||
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
|
||||
test('newest installed read wins over older success and failure without pruning cached data', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
|
||||
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installed.has("new.app")'), true);
|
||||
assert.equal(f.run('st.needsData.has("new.app")'), true);
|
||||
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
|
||||
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
|
||||
f.requests[3].reject(Error('late failure')); await old;
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
assert.equal(f.run('st.installedError'), '');
|
||||
assert.equal(f.run('st.installed.size'), 0);
|
||||
});
|
||||
|
||||
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.view = "installed"');
|
||||
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.equal(f.run('st.installedError'), 'latest offline');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
|
||||
test('catalogue details distinguish unknown and stale installed state', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.context.app = {name: 'Example', package: 'example.app', detailed: true, offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('openDetail(app)');
|
||||
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
const loaded = f.run('loadInstalled()'); await f.answer(f.requests.at(-1), {apps: []}); await loaded;
|
||||
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
});
|
||||
|
||||
test('named native Store dialog enters, contains and restores focus across async redraws', async () => {
|
||||
const html = read('store.html');
|
||||
assert.match(html.match(/<[^>]*id="detail"[^>]*>/)[0], /<dialog\b[^>]*aria-labelledby="detailTitle"/);
|
||||
const f = fixture(html);
|
||||
await f.answer(0, {apps: []});
|
||||
f.context.app = {name: 'Example', offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('st.view = "games"; st.apps = [app]; st.loading = false; render()');
|
||||
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
|
||||
f.run('openDetail(app)');
|
||||
assert.equal(f.element('detail').modalOpened, true);
|
||||
assert.equal(f.element('toast').parent, f.element('detail'));
|
||||
assert.equal(f.document.activeElement.id, 'detailTitle');
|
||||
const install = f.element('sheet').querySelector('[data-act="install"]'); install.focus();
|
||||
await f.answer(2, {description: 'Details arrived'}); // request 1 is bootstrap search
|
||||
assert.equal(f.document.activeElement.dataset.act, 'install');
|
||||
assert.notEqual(f.document.activeElement, install);
|
||||
const close = f.element('sheet').querySelector('[data-act="close"]'); close.focus();
|
||||
f.run('st.jobs.set("test:example", {done: false, stage: "Downloading", percent: 20}); drawDetail(); render()');
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
const keydown = f.element('detail').onkeydown || f.element('detail').listeners.get('keydown');
|
||||
const event = {key: 'Tab', shiftKey: false, preventDefault() {this.prevented = true;}};
|
||||
keydown(event);
|
||||
assert.equal(event.prevented, true);
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close'); // only remaining action
|
||||
f.run('st.jobs.delete("test:example"); drawDetail()');
|
||||
const first = f.element('sheet').querySelector('[data-act="install"]'); first.focus();
|
||||
const reverse = {key: 'Tab', shiftKey: true, preventDefault() {this.prevented = true;}}; keydown(reverse);
|
||||
assert.equal(reverse.prevented, true); assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
first.focus(); f.run('st.jobs.set("test:example", {done: false}); drawDetail()');
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close'); // focused action disappeared
|
||||
f.run('closeDetail()');
|
||||
assert.equal(f.element('detail').open, false);
|
||||
assert.equal(f.element('toast').parent, f.document.body);
|
||||
assert.equal(f.document.activeElement.dataset.i, '0');
|
||||
assert.notEqual(f.document.activeElement, opener); // render replaced the background card
|
||||
});
|
||||
|
||||
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('st.view = "games"; st.apps = [app]; render()');
|
||||
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
|
||||
f.run('openDetail(app)');
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
await f.answer(1, {apps: [f.context.app], sources: []});
|
||||
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
await f.answer(f.requests.at(-1), {apps: []});
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
const cancel = {preventDefault() {this.prevented = true;}};
|
||||
f.element('detail').oncancel(cancel);
|
||||
assert.equal(cancel.prevented, true); assert.equal(f.element('detail').open, false);
|
||||
assert.equal(f.document.activeElement.dataset.i, '0');
|
||||
// Removing the app while details are open falls back to Search on close.
|
||||
f.run('openDetail(app); st.apps = []; render(); closeDetail()');
|
||||
assert.equal(f.document.activeElement, f.element('q'));
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
|
||||
import sandbox # noqa: F401
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_android_import as backup
|
||||
import server
|
||||
from apk_fixtures import apk, manifest
|
||||
|
||||
PKG = 'com.Armature.VR4'
|
||||
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
|
||||
'instance': 2800000001, 'shortcut': 123, 'game_id': 456}
|
||||
|
||||
|
||||
class BackupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.apk = self.root / 'VR4-Android-Shipping-arm64.apk'
|
||||
self.apk.write_bytes(apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, 26)}))
|
||||
self.obb = [self.root / f'{kind}.203.{PKG}.obb' for kind in ('main', 'patch')]
|
||||
for p in self.obb:
|
||||
p.write_bytes(b'game data')
|
||||
|
||||
def test_desktop_apk_finds_matching_siblings_but_browser_requires_selection(self):
|
||||
(self.root / 'main.1.org.other.game.obb').write_bytes(b'other game')
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
desktop = backup.inspect([str(self.apk)], discover=True)
|
||||
browser = backup.inspect([str(self.apk)])
|
||||
selected = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
ssh.assert_not_called()
|
||||
self.assertEqual(desktop['obb'], self.obb)
|
||||
self.assertEqual(browser['obb'], [])
|
||||
self.assertEqual(backup.public(selected)['obb'][0]['name'], self.obb[0].name)
|
||||
self.assertEqual(backup.public(selected)['package'], PKG)
|
||||
self.assertNotIn(str(self.root), str(backup.public(selected)))
|
||||
|
||||
def test_wrong_data_empty_data_duplicate_names_and_multiple_apks_fail_before_install(self):
|
||||
other = self.root / 'main.203.org.other.game.obb'
|
||||
other.write_bytes(b'other game')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'does not belong'):
|
||||
backup.inspect([str(self.apk), str(other)])
|
||||
self.obb[0].write_bytes(b'')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'nonempty'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0])])
|
||||
second = self.root / 'another.apk'
|
||||
second.write_bytes(self.apk.read_bytes())
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'one game at a time'):
|
||||
backup.inspect([str(self.apk), str(second)])
|
||||
self.obb[0].write_bytes(b'data')
|
||||
folder = self.root / 'copy'
|
||||
folder.mkdir()
|
||||
duplicate = folder / self.obb[0].name
|
||||
duplicate.write_bytes(b'data')
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
|
||||
|
||||
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
|
||||
with patch.object(backup.android, 'read_meta', return_value=None):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
|
||||
backup.inspect(list(map(str, self.obb)))
|
||||
with patch.object(backup.android, 'read_meta', return_value=META):
|
||||
plan = backup.inspect(list(map(str, self.obb)))
|
||||
self.assertIsNone(plan['apk'])
|
||||
self.assertEqual(plan['checked']['version_code'], 210)
|
||||
|
||||
def test_apk_and_both_data_files_install_launch_verify_copy_close_in_order(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
order = []
|
||||
def added(pkg, checked, files, *args, **kwargs):
|
||||
order.append('copy-close')
|
||||
self.assertEqual(pkg, PKG)
|
||||
self.assertEqual(files, self.obb)
|
||||
return {'closed': True, 'message': 'copied', 'app': META, 'needs_game_data': False}
|
||||
with patch.object(backup.quest, '_running', side_effect=[False, False, True]), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', side_effect=lambda *a, **k: order.append('install') or META), \
|
||||
patch.object(backup.android, 'launch', side_effect=lambda *a: order.append('launch')), \
|
||||
patch.object(backup.quest, '_wait_ready', side_effect=lambda *a: order.append('ready')), \
|
||||
patch.object(backup.quest, '_check_android_package', side_effect=lambda *a: order.append('verify')), \
|
||||
patch.object(backup.quest, 'add_game_data', side_effect=added):
|
||||
result = backup.install(plan)
|
||||
self.assertEqual(order, ['install', 'launch', 'ready', 'verify', 'copy-close'])
|
||||
self.assertIn('2 game-data files', result['message'])
|
||||
self.assertIn('Ready to open', result['message'])
|
||||
|
||||
def test_copy_failure_closes_game_and_keeps_retry_instruction(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
with patch.object(backup.quest, '_running', side_effect=[False, True]), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META), \
|
||||
patch.object(backup.quest, '_wait_ready'), patch.object(backup.quest, '_check_android_package'), \
|
||||
patch.object(backup.quest, 'add_game_data', side_effect=backup.android.FrameError('transfer failed')), \
|
||||
patch.object(backup.quest, '_close', return_value=(True, None)) as close:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'installed, but.*Import the same backup again'):
|
||||
backup.install(plan)
|
||||
close.assert_called_once_with(PKG)
|
||||
|
||||
def test_changed_files_are_rejected_before_frame_access(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
self.obb[0].write_bytes(b'changed contents')
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'backup changed'):
|
||||
backup.install(plan)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_running_game_and_newer_installed_version_are_not_replaced(self):
|
||||
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
|
||||
newer = {**META, 'version_code': plan['checked']['version_code'] + 1}
|
||||
for running, existing, message in [(True, None, 'Close'), (False, newer, 'newer')]:
|
||||
with self.subTest(running=running), \
|
||||
patch.object(backup.quest, '_running', return_value=running), \
|
||||
patch.object(backup.android, 'read_meta', return_value=existing), \
|
||||
patch.object(backup.android, 'install') as install:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.install(plan)
|
||||
install.assert_not_called()
|
||||
|
||||
def test_apk_without_data_installs_without_launching(self):
|
||||
plan = backup.inspect([str(self.apk)])
|
||||
with patch.object(backup.quest, '_running', return_value=False), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META), \
|
||||
patch.object(backup.android, 'launch') as launch:
|
||||
result = backup.install(plan)
|
||||
launch.assert_not_called()
|
||||
self.assertFalse(result['needs_game_data'])
|
||||
|
||||
|
||||
class StagingTests(unittest.TestCase):
|
||||
setUp = BackupTests.setUp
|
||||
def tearDown(self):
|
||||
for entry in server._android_backups.values():
|
||||
server._discard_android_backup(entry)
|
||||
server._android_backups.clear()
|
||||
|
||||
def test_uploaded_batch_is_inspected_together_and_cancel_removes_all_uploads(self):
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
for path in [self.apk, *self.obb]:
|
||||
class Request:
|
||||
headers = {'X-Filename': path.name, 'X-Mode': 'android-backup',
|
||||
'X-Android-Backup': token, 'Content-Length': str(path.stat().st_size)}
|
||||
rfile = io.BytesIO(path.read_bytes())
|
||||
server.Handler.upload(Request())
|
||||
read = server.android_backup({'action': 'inspect', 'token': token})
|
||||
self.assertEqual(len(read['plan']['obb']), 2)
|
||||
folders = list(server._android_backups[token]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': token})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(self.apk.exists())
|
||||
|
||||
def test_headset_change_refuses_import_and_pc_folder_is_left_to_title_importer(self):
|
||||
class Link:
|
||||
device = 'a'
|
||||
def active_device(self):
|
||||
return {'id': self.device}
|
||||
link = Link()
|
||||
with patch.object(server, 'LINK', link):
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(self.apk)]})
|
||||
link.device = 'b'
|
||||
with self.assertRaisesRegex(server.Failure, 'headset changed'):
|
||||
server.android_backup({'action': 'install', 'token': read['token']})
|
||||
pc = self.root / 'pc'
|
||||
pc.mkdir()
|
||||
(pc / 'game.exe').write_bytes(b'program')
|
||||
self.assertEqual(server.android_backup({'action': 'inspect', 'paths': [str(pc)], 'probe': True}),
|
||||
{'android': False})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -19,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
_images._cache.clear()
|
||||
_images._dns.clear()
|
||||
|
||||
def test_only_registered_source_images_are_fetchable(self):
|
||||
with patch.object(_images, 'fetch') as fetch:
|
||||
@@ -71,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
|
||||
_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)
|
||||
with patch.object(_images, 'MAX_TOKENS', 50):
|
||||
first = _images.register('https://example.com/0.png')
|
||||
for i in range(1, 60):
|
||||
_images.register('https://example.com/%d.png' % i)
|
||||
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
|
||||
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
|
||||
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
|
||||
|
||||
def test_plain_language_verdict_is_evidence_based(self):
|
||||
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
|
||||
|
||||
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
|
||||
break
|
||||
time.sleep(.01)
|
||||
self.assertTrue(arrived[1]['event'].wait(2))
|
||||
self.assertEqual(arrived[1]['query'], ('second', 50))
|
||||
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
|
||||
|
||||
def test_set_enabled_does_not_hold_search_lock_in_source(self):
|
||||
free = []
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Exercise the production preference bootstrap across separate web origins."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page behavior')
|
||||
class ClientPreferences(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/client_preferences.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -63,6 +63,20 @@ class Coverage(unittest.TestCase):
|
||||
self.assertIn('*.js', ui['filter'])
|
||||
self.assertIn('*.css', ui['filter'])
|
||||
|
||||
def test_bundle_runs_the_server_on_its_own(self):
|
||||
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
|
||||
import io, tarfile, tempfile
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
|
||||
tar.extractall(tmp)
|
||||
result = subprocess.run([sys.executable, '-I', '-B', '-c',
|
||||
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
|
||||
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
|
||||
cwd=tmp, capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
|
||||
@@ -323,6 +323,15 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
|
||||
d = self.reg.add_device('frame', name='First')
|
||||
before = self.reg.path.stat()
|
||||
other = fd.Registry(self.reg.path)
|
||||
other.update_device(d['id'], name='Other')
|
||||
os.utime(self.reg.path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
self.assertEqual(self.reg.path.stat().st_size, before.st_size)
|
||||
self.assertEqual(self.reg.get(d['id'])['name'], 'Other')
|
||||
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
|
||||
@@ -141,6 +141,9 @@ def wait_for_idle_resolvers(timeout=10):
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wait_for_idle_resolvers()
|
||||
from apk_sources import _images
|
||||
_images._dns.clear() # each test answers lookups its own way
|
||||
self.addCleanup(_images._dns.clear)
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""iPhone packaging and Foundation contracts; no headset, downloads or Xcode build."""
|
||||
import sandbox # noqa: F401
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BundleContracts(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
spec = importlib.util.spec_from_file_location("ios_bundle_contract", ROOT / "ios/scripts/make_frame_bundle.py")
|
||||
cls.bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.bundle)
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.root = Path(cls.tmp.name)
|
||||
with tarfile.open(fileobj=io.BytesIO(cls.bundle.build()), mode="r:gz") as archive:
|
||||
archive.extractall(cls.root)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def test_required_resources_ship_to_both_clients(self):
|
||||
resources = ["frame/android/library_artwork.js", "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"]
|
||||
config = json.loads((ROOT / "app/package.json").read_text())
|
||||
for resource in resources:
|
||||
with self.subTest(resource=resource):
|
||||
self.assertTrue((self.root / resource).is_file(), resource)
|
||||
# Match the desktop's real resource filters as well as the phone archive.
|
||||
packaged = False
|
||||
for entry in config["build"]["extraResources"]:
|
||||
source = entry["from"].removeprefix("../")
|
||||
if Path(resource).is_relative_to(source):
|
||||
relative = str(Path(resource).relative_to(source))
|
||||
packaged |= any(fnmatch.fnmatch(relative, pattern) for pattern in entry.get("filter", ["**/*"]))
|
||||
self.assertTrue(packaged, "desktop resource contract: " + resource)
|
||||
|
||||
def test_extracted_runtime_can_render_and_load_xr_layer(self):
|
||||
result = subprocess.run([sys.executable, "-I", "-B", "-c", """
|
||||
import sys, zipfile
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0, 'ui')
|
||||
import frame_android as android
|
||||
with patch.object(android, 'ssh', return_value='rendered') as ssh:
|
||||
assert android.shortcut_tool('render', '/synthetic/app') == 'rendered'
|
||||
assert 'ART_RENDERER = ' in ssh.call_args.kwargs['input']
|
||||
assert len(ssh.call_args.kwargs['input']) > 1000
|
||||
with zipfile.ZipFile('synthetic.apk', 'w') as apk:
|
||||
apk.writestr('lib/arm64-v8a/libopenxr_loader.so', b'synthetic loader')
|
||||
files = android.xr_compat_files('synthetic.apk')
|
||||
assert set(files) == set(android.XR_COMPAT_FILES)
|
||||
assert files['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\\x7fELF')
|
||||
"""], cwd=self.root, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class NativeContracts(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS Swift compiles actual Foundation production sources")
|
||||
def test_foundation_contracts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
main = Path(tmp) / "main.swift"
|
||||
main.write_text("import XCTest\nlet suite = XCTestSuite(forTestCaseClass: NativeSessionTests.self)\nsuite.run()\nexit(suite.testRun!.hasSucceeded ? 0 : 1)\n")
|
||||
executable = Path(tmp) / "native-contracts"
|
||||
developer = subprocess.check_output(["xcode-select", "-p"], text=True).strip()
|
||||
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
|
||||
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
|
||||
shared = str(Path(developer).parent / "SharedFrameworks")
|
||||
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
|
||||
"-Xlinker", "-rpath", "-Xlinker", libraries,
|
||||
"-Xlinker", "-rpath", "-Xlinker", shared,
|
||||
"-D", "NATIVE_CONTRACT_STANDALONE",
|
||||
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
||||
str(ROOT / "ios/FrameControlTests/NativeSessionTests.swift"),
|
||||
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Executed 5 tests", result.stdout + result.stderr)
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS exercises the production finder with controlled probes")
|
||||
def test_finder_ignores_late_probes_after_edit_and_stop(self):
|
||||
harness = r'''import Foundation
|
||||
actor Probes {
|
||||
var pending: [String: CheckedContinuation<Bool, Never>] = [:]
|
||||
func probe(_ host: String) async -> Bool {
|
||||
await withCheckedContinuation { pending[host] = $0 }
|
||||
}
|
||||
func has(_ host: String) -> Bool { pending[host] != nil }
|
||||
func answer(_ host: String) { pending.removeValue(forKey: host)!.resume(returning: true) }
|
||||
}
|
||||
@main struct FinderContracts {
|
||||
@MainActor static func wait(_ condition: () async -> Bool) async {
|
||||
for _ in 0..<100 {
|
||||
if await condition() { return }
|
||||
try! await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
fatalError("controlled callback did not run")
|
||||
}
|
||||
@MainActor static func main() async {
|
||||
let probes = Probes()
|
||||
let finder = FrameFinder(probe: { await probes.probe($0) }, discoveryEnabled: false)
|
||||
finder.start(fallback: "old.local")
|
||||
await wait { await probes.has("old.local") }
|
||||
finder.start(fallback: "100.100.100.2:2222", manual: true)
|
||||
await wait { await probes.has("100.100.100.2:2222") }
|
||||
await probes.answer("old.local")
|
||||
// Let the cancelled task return its deliberately late successful answer.
|
||||
try! await Task.sleep(nanoseconds: 20_000_000)
|
||||
precondition(finder.found == nil, "A stale probe replaced the edited target")
|
||||
await probes.answer("100.100.100.2:2222")
|
||||
await wait { finder.found != nil }
|
||||
precondition(finder.found?.host == "100.100.100.2:2222")
|
||||
finder.start(fallback: "stopped.local", manual: true)
|
||||
await wait { await probes.has("stopped.local") }
|
||||
finder.stop()
|
||||
await probes.answer("stopped.local")
|
||||
try! await Task.sleep(nanoseconds: 20_000_000)
|
||||
precondition(finder.found == nil, "A stopped search published a result")
|
||||
print("Production FrameFinder: stale and cancelled probes ignored; manual target retained")
|
||||
}
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
main = Path(tmp) / "FinderContracts.swift"
|
||||
main.write_text(harness)
|
||||
executable = Path(tmp) / "finder-contracts"
|
||||
result = subprocess.run(["swiftc", "-parse-as-library",
|
||||
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
||||
str(ROOT / "ios/FrameControl/SSH/FrameFinder.swift"),
|
||||
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node executes the unmodified production bridge script")
|
||||
def test_bridge_preferences_and_document_acknowledgement(self):
|
||||
source = (ROOT / "ios/FrameControl/Web/WebShell.swift").read_text()
|
||||
bridge = source.split('static let bridge = """', 1)[1].split('"""', 1)[0]
|
||||
script = r'''const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const calls = [];
|
||||
const window = {webkit: {messageHandlers: {frameApp: {postMessage: body => {calls.push(body); return Promise.resolve(true)}}}}};
|
||||
vm.runInNewContext(process.argv[1], {window, crypto: {randomUUID: () => 'document-42'}});
|
||||
assert.equal(window.__frameInstallLink({}), false);
|
||||
window.frameApp.preferences.read();
|
||||
window.frameApp.preferences.write('mvSeparate', '0');
|
||||
assert.equal(JSON.stringify(calls), JSON.stringify([
|
||||
{name: 'preferencesRead', arg: null}, {name: 'preferencesWrite', arg: {key: 'mvSeparate', value: '0'}}
|
||||
]));
|
||||
const received = [];
|
||||
window.frameApp.onInstallLink(req => received.push(req));
|
||||
assert.equal(calls.at(-1).name, 'installLinkReady');
|
||||
assert.equal(calls.at(-1).arg, 'document-42');
|
||||
assert.equal(window.__frameInstallLink({target: 'a'}), true);
|
||||
assert.equal(received[0].target, 'a');
|
||||
window.frameApp.onInstallLink(() => {throw new Error('callback failed')});
|
||||
assert.throws(() => window.__frameInstallLink({}), /callback failed/);
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", script, bridge], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -84,10 +84,7 @@ class Helpers(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
|
||||
if sys.platform == "win32" and not Path(bash).exists():
|
||||
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
|
||||
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
|
||||
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_show_checks_the_source_before_anything_else(self):
|
||||
|
||||
@@ -9,7 +9,8 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
const vm = require('vm');
|
||||
@@ -31,8 +32,9 @@ class PageStartup(unittest.TestCase):
|
||||
def test_opening_any_page_or_section_at_startup_runs(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||
# The routing tables and the workspace helpers showPage uses, up to showPage itself.
|
||||
start = first.index('function showPage(')
|
||||
tables = first[first.index('const PAGES ='):start]
|
||||
show = first[start:first.index('\n}\n', start) + 3]
|
||||
# Everything declared after the startup call is still uninitialised when it runs.
|
||||
call = re.search(r'^showPage\(\);', first, re.M).end()
|
||||
@@ -41,10 +43,11 @@ class PageStartup(unittest.TestCase):
|
||||
self.assertIn('link', later) # the name that broke #games
|
||||
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
||||
for n in STUBS if n != '$')
|
||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
||||
node = '{ classList: { toggle() {} }, scrollIntoView() {}, append() {}, prepend() {}, setAttribute() {}, removeAttribute() {} }'
|
||||
code = (f'const $ = id => id === "nowhere" ? null : {node};\nconst gm = {{src: "owned"}};\n' + stubs + tables + show +
|
||||
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
||||
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
|
||||
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
||||
hashes += ['#' + k for k in re.findall(r'(\w+): ?"', tables[:tables.index('const workspace')])]
|
||||
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Frozen regression trace from the original mac-in-headset Swift controller.
|
||||
The 600-state reference was produced at 1b90c64, before the C extraction:
|
||||
60 seconds with a slow/congested middle, then recovery. No generated behavior
|
||||
from the new implementation is used to calculate the expected digest.
|
||||
"""
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
from frame_pc_capture import Controller
|
||||
|
||||
|
||||
class ControllerTrace(unittest.TestCase):
|
||||
def test_original_mac_congestion_and_recovery(self):
|
||||
cc = shutil.which('cc') or shutil.which('gcc')
|
||||
if not cc:
|
||||
self.skipTest('C compiler not installed; native build jobs cover this trace')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
|
||||
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
|
||||
lib = C.CDLL(str(path))
|
||||
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
|
||||
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
|
||||
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
|
||||
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
|
||||
for name, (ret, args) in sig.items():
|
||||
fn = getattr(lib, 'fc_'+name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
c = Controller(lib, 60, 8000000)
|
||||
due, states, seq = [], [], 0
|
||||
try:
|
||||
for tick in range(6000):
|
||||
now = 10000000+tick*10000
|
||||
for t, s in due:
|
||||
if t <= now:
|
||||
c.call('ack', s, now)
|
||||
due = [(t, s) for t, s in due if t > now]
|
||||
if tick % 2 == 0:
|
||||
c.call('capture', now)
|
||||
if c.call('gate', now, 1):
|
||||
seq += 1
|
||||
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
|
||||
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
|
||||
if tick % 10 == 0:
|
||||
c.update(now)
|
||||
x = c.state()
|
||||
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
|
||||
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
|
||||
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
|
||||
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
|
||||
finally:
|
||||
c.close()
|
||||
if sys.platform == 'win32':
|
||||
import _ctypes
|
||||
_ctypes.FreeLibrary(lib._handle)
|
||||
@@ -1,388 +0,0 @@
|
||||
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
|
||||
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
|
||||
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
|
||||
successful real-host capture.
|
||||
"""
|
||||
import ctypes
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_pc_agent as agent
|
||||
import frame_pc_capture as capture
|
||||
import frame_pcview
|
||||
import frame_macview
|
||||
from frame_stream_stats import Stats
|
||||
from test_macview import WS
|
||||
|
||||
|
||||
class GrantsTests(unittest.TestCase):
|
||||
def test_source_bound_retry_ack_and_stop(self):
|
||||
g = agent.Grants('master')
|
||||
ticket = g.ticket('window:1')
|
||||
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
|
||||
key = g.redeem('window:1', {'t': ticket})
|
||||
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
|
||||
g.ack(key)
|
||||
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
|
||||
self.assertEqual(key, g.redeem('window:1', {'r': key}))
|
||||
self.assertIsNone(g.redeem('window:2', {'r': key}))
|
||||
g.revoke('window:1')
|
||||
self.assertIsNone(g.redeem('window:1', {'r': key}))
|
||||
|
||||
def test_expiry_and_stop_before_redeem(self):
|
||||
g = agent.Grants('master')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=1):
|
||||
ticket = g.ticket('test')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=62):
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
ticket = g.ticket('test')
|
||||
g.revoke()
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
|
||||
|
||||
class AdapterTests(unittest.TestCase):
|
||||
def test_windows_uses_wgc_and_media_foundation(self):
|
||||
for kind in ('window', 'display'):
|
||||
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('capture-api=wgc', pipeline)
|
||||
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
|
||||
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
|
||||
self.assertNotIn('drop=true', pipeline)
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_linux_uses_only_portal_fd_and_node(self):
|
||||
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('pipewiresrc fd=19 path=47', p)
|
||||
self.assertIn('tune=zerolatency', p)
|
||||
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
|
||||
|
||||
def test_portal_input_clamps_and_releases_held_state(self):
|
||||
native = mock.Mock()
|
||||
native.lib.fc_portal_input.return_value = 1
|
||||
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
|
||||
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
|
||||
inp.handle(dict(t='k', e='down', key='Control'))
|
||||
inp.release()
|
||||
calls = native.lib.fc_portal_input.call_args_list
|
||||
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
|
||||
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
|
||||
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
|
||||
self.assertFalse(inp.buttons or inp.keys)
|
||||
|
||||
def test_windows_release_and_negative_monitor_origin(self):
|
||||
host = mock.Mock()
|
||||
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
|
||||
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
|
||||
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
|
||||
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
|
||||
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
|
||||
inp.release()
|
||||
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
|
||||
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
|
||||
|
||||
def test_refused_windows_focus_never_clicks_the_covering_app(self):
|
||||
host = mock.Mock()
|
||||
host.rect.return_value = (0, 0, 800, 600)
|
||||
host.user.GetForegroundWindow.return_value = 99
|
||||
host.user.SetForegroundWindow.return_value = False
|
||||
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
|
||||
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
|
||||
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
|
||||
host.send.assert_not_called()
|
||||
|
||||
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
|
||||
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
|
||||
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
|
||||
view.ensure_tunnel = mock.Mock()
|
||||
view.remote_port = 47900
|
||||
view.show('window:12')
|
||||
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
|
||||
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
|
||||
self.assertFalse(view.prefer_usb)
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
view.show('separate:12')
|
||||
|
||||
def test_session_end_releases_native_input_only_once(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event = threading.Event()
|
||||
session.ws, session.input = mock.Mock(), mock.Mock()
|
||||
session.released = False
|
||||
session.end()
|
||||
session.end()
|
||||
session.input.release.assert_called_once()
|
||||
self.assertTrue(session.stop_event.is_set())
|
||||
|
||||
def test_idle_source_is_not_a_stall(self):
|
||||
# PipeWire/WGC deliver frames only on damage: a static desktop sends
|
||||
# its first frame and then nothing. That must not end the session.
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.pending = threading.RLock(), {}
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.stats.captured = 1
|
||||
self.assertIsNone(session.stalled(60000000, 0, 0))
|
||||
# A frame that entered the encoder and never came out is a fault.
|
||||
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
|
||||
self.assertIn('encoder', session.stalled(12000000, 0, 0))
|
||||
self.assertIsNone(session.stalled(10000000, 0, 0))
|
||||
# No initial frame at all after the pipeline opened is a fault.
|
||||
session.pending.clear()
|
||||
self.assertIn('capture', session.stalled(12000000, 0, 1))
|
||||
self.assertIsNone(session.stalled(9000000, 0, 1))
|
||||
|
||||
def test_controller_is_inert_after_close(self):
|
||||
lib = mock.Mock()
|
||||
lib.fc_new.return_value = 1234
|
||||
lib.fc_value.return_value = 100
|
||||
controller = capture.Controller(lib, 60, 5000000)
|
||||
controller.close()
|
||||
lib.fc_free.assert_called_once_with(1234)
|
||||
lib.reset_mock()
|
||||
state = controller.state()
|
||||
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
|
||||
self.assertEqual(controller.call('gate', 1, 1), 0)
|
||||
self.assertEqual(controller.update(1), 0)
|
||||
controller.close()
|
||||
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
|
||||
|
||||
def test_library_path_prepend_adds_no_empty_entry(self):
|
||||
env = {}
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
|
||||
with mock.patch.dict(os.environ, clear=True):
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
for name in ('PATH', 'LD_LIBRARY_PATH'):
|
||||
parts = env.get(name, 'x').split(os.pathsep)
|
||||
self.assertNotIn('', parts, name)
|
||||
|
||||
def test_malformed_viewer_input_is_dropped_not_fatal(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event, session.key_event = threading.Event(), threading.Event()
|
||||
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
|
||||
session.source = dict(src='display:1', w=100, h=100, portal=1)
|
||||
session.input_enabled, session.released = True, False
|
||||
session.native = mock.Mock()
|
||||
session.native.now.return_value = 0
|
||||
session.controller, session.agent = mock.Mock(), mock.Mock()
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.produce = lambda: None
|
||||
lib = mock.Mock()
|
||||
lib.fc_portal_input.return_value = 1
|
||||
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
|
||||
session.ws = mock.Mock()
|
||||
session.ws.receive.side_effect = [
|
||||
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
|
||||
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
|
||||
with self.assertRaises(ConnectionError):
|
||||
session.start()
|
||||
# Every malformed message was skipped; the good move after them still ran.
|
||||
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
|
||||
self.assertAlmostEqual(moves[-1][2], .5*99)
|
||||
self.assertEqual(session.ws.receive.call_count, 7)
|
||||
|
||||
def test_stats_keep_capture_time_and_bound_records(self):
|
||||
stats = Stats(lambda: 10000000)
|
||||
stats.input(dict(t='m', i=1, tv=9999990))
|
||||
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
|
||||
self.assertEqual(f['echo'], 1)
|
||||
stats.report(dict(t='rx', s=f['s'], r=10000010))
|
||||
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
|
||||
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
|
||||
for _ in range(4100):
|
||||
stats.add(cap=10000001)
|
||||
self.assertEqual(len(stats.frames), 4096)
|
||||
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not capture.LIBRARY.exists():
|
||||
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
|
||||
raise AssertionError('CI required native PC libraries but they were not built')
|
||||
raise unittest.SkipTest('native PC libraries not built on this host')
|
||||
cls.token = 'test-' + os.urandom(8).hex()
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
|
||||
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env)
|
||||
line = cls.proc.stdout.readline()
|
||||
if 'listening' not in line:
|
||||
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
|
||||
cls.port = int(line.rsplit(':', 1)[1])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.stdin.close()
|
||||
try:
|
||||
rc = cls.proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
raise AssertionError('Native agent did not stop after EOF')
|
||||
error = cls.proc.stderr.read()
|
||||
cls.proc.stdout.close()
|
||||
cls.proc.stderr.close()
|
||||
if rc:
|
||||
raise AssertionError('Native agent exited %s: %s' % (rc, error))
|
||||
|
||||
def request(self, path, method='GET'):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
|
||||
try:
|
||||
conn.request(method, path)
|
||||
response = conn.getresponse()
|
||||
return response.status, response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.request('/close?k='+self.token, 'POST')
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
|
||||
def test_bundled_pipewire_context_needs_no_system_modules(self):
|
||||
# No portal request, remote connection or desktop capture: create and
|
||||
# destroy a private client context to test the dynamically loaded SPA
|
||||
# and protocol modules that ldd cannot discover.
|
||||
code = """
|
||||
import ctypes as C, os
|
||||
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
|
||||
for name, ret, args in [
|
||||
('pw_init', None, [C.c_void_p, C.c_void_p]),
|
||||
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
|
||||
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
|
||||
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
|
||||
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_destroy', None, [C.c_void_p]),
|
||||
('pw_main_loop_destroy', None, [C.c_void_p])]:
|
||||
fn = getattr(lib, name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
lib.pw_init(None, None)
|
||||
loop = lib.pw_main_loop_new(None)
|
||||
assert loop, 'bundled SPA loop support did not load'
|
||||
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
|
||||
assert context, 'bundled PipeWire client modules did not load'
|
||||
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
|
||||
# The exported factory is video.adapt, which needs a live follower node to
|
||||
# instantiate. Check its configured library and exported factory without
|
||||
# pretending a headless context is a consented video stream.
|
||||
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
|
||||
assert plugin_name, 'video.adapt has no bundled library mapping'
|
||||
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
|
||||
class Factory(C.Structure):
|
||||
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
|
||||
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
|
||||
plugin.spa_handle_factory_enum.restype = C.c_int
|
||||
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
|
||||
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
|
||||
names.append(factory.contents.name)
|
||||
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
|
||||
lib.pw_context_destroy(context)
|
||||
lib.pw_main_loop_destroy(loop)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_last_raw_picture_survives_a_closed_gate(self):
|
||||
# A source sends ONE changed frame, then becomes idle. It must reach
|
||||
# the encoder after congestion clears, without needing another update.
|
||||
code = """
|
||||
import ctypes as C, sys, time
|
||||
from frame_pc_capture import Native, GATE, Encoded, pipeline
|
||||
native = Native()
|
||||
start = None
|
||||
closing = False
|
||||
phases = []
|
||||
def gate(stage, pts, capture, arrived):
|
||||
global start
|
||||
phases.append(stage)
|
||||
if closing: return -1
|
||||
if start is None: start = time.monotonic()
|
||||
return int(stage == 1 or time.monotonic()-start >= .15)
|
||||
callback = GATE(gate)
|
||||
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
|
||||
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
|
||||
error = C.create_string_buffer(1024)
|
||||
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
|
||||
assert handle, error.value
|
||||
try:
|
||||
frame = Encoded()
|
||||
result = 0
|
||||
deadline = time.monotonic()+5
|
||||
while not result and time.monotonic() < deadline:
|
||||
result = native.lib.fc_capture_pull(handle, C.byref(frame))
|
||||
assert result == 1, native.lib.fc_capture_error(handle)
|
||||
assert frame.key and frame.size > 17
|
||||
assert phases.count(0) == 1 and 2 in phases, phases
|
||||
finally:
|
||||
closing = True
|
||||
native.lib.fc_capture_close(handle)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT / 'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_auth_and_real_h264_stats(self):
|
||||
self.assertEqual(self.request('/status')[0], 403)
|
||||
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
|
||||
self.assertEqual(status, 200)
|
||||
ticket = json.loads(body)['ticket']
|
||||
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
|
||||
try:
|
||||
self.assertEqual(ws.status, 101)
|
||||
key, got, echo = None, 0, False
|
||||
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
|
||||
deadline = time.monotonic()+10
|
||||
while time.monotonic() < deadline and (got < 5 or not echo):
|
||||
op, data = ws.recv()
|
||||
if op == 1:
|
||||
m = json.loads(data)
|
||||
self.assertNotEqual(m.get('t'), 'error', m)
|
||||
if m.get('t') == 'hello':
|
||||
key = m['r']
|
||||
ws.send_text(json.dumps(dict(t='ack')))
|
||||
elif op == 2:
|
||||
got += 1
|
||||
seq, echoed = struct.unpack('!II', data[9:17])
|
||||
now = struct.unpack('!Q', data[1:9])[0]
|
||||
echo |= echoed == 7
|
||||
self.assertIn(b'\x00\x00\x00\x01', data[17:])
|
||||
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
|
||||
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
|
||||
self.assertGreaterEqual(got, 5)
|
||||
self.assertTrue(echo)
|
||||
time.sleep(.1)
|
||||
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
|
||||
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
|
||||
self.assertIn('controller', stats)
|
||||
used = WS(self.port, '/stream?src=test&t='+ticket)
|
||||
self.assertEqual(used.status, 403)
|
||||
used.close()
|
||||
self.request('/close?src=test&k='+self.token, 'POST')
|
||||
revoked = WS(self.port, '/stream?src=test&r='+key)
|
||||
self.assertEqual(revoked.status, 403)
|
||||
revoked.close()
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_store_page_served_with_anti_framing_headers(self):
|
||||
status, headers, payload = self.request("GET", "/store")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<title>APK Store</title>", payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Offline production-seam coverage for SERVICES01–04; no headset or provider calls."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import server
|
||||
import frame_agent as agent
|
||||
import frame_android as android
|
||||
|
||||
|
||||
class TargetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.active = {'id': 'a', 'name': 'Headset A'}
|
||||
self.link = mock.Mock()
|
||||
self.link.active_device.side_effect = lambda: dict(self.active)
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.stack.enter_context(mock.patch.object(server, 'LINK', self.link))
|
||||
self.stack.enter_context(mock.patch.object(server, 'PRIVATE', False))
|
||||
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
|
||||
self.stack.enter_context(mock.patch.object(server, '_backfill', {'running': False, 'last': 0.0}))
|
||||
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
|
||||
self.stack.enter_context(mock.patch.object(server._input, 'stop'))
|
||||
self.stack.enter_context(mock.patch.object(server.frame_link, 'devices_action', self.switch))
|
||||
|
||||
def switch(self, link, body, setup, busy):
|
||||
if busy():
|
||||
raise server.frame_devices.DeviceError('Wait for work')
|
||||
self.active.update(id='b', name='Headset B')
|
||||
return {'message': 'switched'}
|
||||
|
||||
def test_route_closes_touch_and_never_sends_to_old_process(self):
|
||||
touch = server.TouchAgent()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
touch.proc, touch.status = proc, {'state': 'ready'}
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
try:
|
||||
with mock.patch.object(server, '_touch', touch), mock.patch.object(touch, 'start'):
|
||||
server.route('headset-b', [])
|
||||
result = server.remote_touch({'events': [{'dx': 17, 'dy': -4}]})
|
||||
self.assertFalse(result['sent'])
|
||||
self.assertEqual(touch.status['state'], 'off')
|
||||
proc.stdin.write.assert_not_called()
|
||||
self.assertTrue(proc.stdin.close.called or proc.terminate.called)
|
||||
finally:
|
||||
server.route(*before)
|
||||
|
||||
def test_route_invalidates_touch_start_already_inside_popen(self):
|
||||
touch = server.TouchAgent()
|
||||
touch.launching = touch.generation
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
def popen(*a, **kw):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError('test did not release launch')
|
||||
return proc
|
||||
with mock.patch.object(server, '_touch', touch), \
|
||||
mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(server.subprocess, 'Popen', side_effect=popen), \
|
||||
mock.patch.object(touch, '_watch', return_value=(False, True)):
|
||||
thread = threading.Thread(target=touch._launch, args=(0,))
|
||||
thread.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(2))
|
||||
server.route('headset-b', [])
|
||||
release.set()
|
||||
thread.join(2)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertIsNone(touch.proc)
|
||||
self.assertEqual(touch.status['state'], 'off')
|
||||
self.assertTrue(proc.terminate.called or proc.stdin.close.called)
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(2)
|
||||
server._live_tunnels.discard(proc)
|
||||
server.route(*before)
|
||||
|
||||
def test_backfill_counts_work_until_error_cleanup(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
writes = []
|
||||
def check(*a, **kw):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError('test did not release backfill')
|
||||
def refresh(*a, **kw):
|
||||
writes.append(self.active['id'])
|
||||
raise RuntimeError('art failure')
|
||||
original = server.threading.Thread
|
||||
threads = []
|
||||
def thread_factory(*a, **kw):
|
||||
thread = original(*a, **kw)
|
||||
threads.append(thread)
|
||||
return thread
|
||||
with mock.patch.object(server.frame_android, 'shortcut_tool', side_effect=check), \
|
||||
mock.patch.object(server.frame_android, 'refresh_art', side_effect=refresh), \
|
||||
mock.patch.object(server.threading, 'Thread', side_effect=thread_factory):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
try:
|
||||
self.assertTrue(entered.wait(2))
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
finally:
|
||||
release.set()
|
||||
for thread in threads:
|
||||
thread.join(2)
|
||||
self.assertEqual(writes, ['a'])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
|
||||
def test_listing_cannot_switch_before_backfill_registration(self):
|
||||
handler = server.Handler.__new__(server.Handler)
|
||||
handler.path = '/api/android'
|
||||
handler.local_request = lambda: True
|
||||
handler.send_json = mock.Mock()
|
||||
handler.send_error_json = mock.Mock()
|
||||
attempts = []
|
||||
def listing():
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
return []
|
||||
with mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(android, 'list_apps', side_effect=listing), \
|
||||
mock.patch.object(server, 'backfill_art') as backfill, \
|
||||
mock.patch.object(server.apk_search, 'artwork_for', return_value={}):
|
||||
handler.do_GET()
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
backfill.assert_called_once()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_failed_worker_start_releases_count_and_throttle(self):
|
||||
with mock.patch.object(server.threading.Thread, 'start', side_effect=RuntimeError('cannot start')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'cannot start'):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
self.assertEqual(server._backfill['last'], 0)
|
||||
|
||||
def test_backfill_refuses_identifiers_from_an_old_listing_target(self):
|
||||
self.active.update(id='b', name='Headset B')
|
||||
with mock.patch.object(android, 'refresh_art') as refresh:
|
||||
with self.assertRaises(server.Failure):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}], meant='a')
|
||||
refresh.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
|
||||
def test_failed_thread_constructor_releases_count_and_throttle(self):
|
||||
with mock.patch.object(server.threading, 'Thread', side_effect=RuntimeError('thread allocation')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'thread allocation'):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertEqual(server._backfill, {'running': False, 'last': 0})
|
||||
|
||||
def test_approval_same_device_runs_once_and_releases_work_on_error(self):
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), \
|
||||
mock.patch.object(server, 'launch', side_effect=RuntimeError('launch failed')) as launch:
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
with self.assertRaisesRegex(RuntimeError, 'launch failed'):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
with self.assertRaises(ValueError):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
launch.assert_called_once()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_approval_records_target_and_rejects_switch(self):
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch') as launch:
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
self.assertEqual(approval['action']['target'], self.active)
|
||||
self.assertEqual(agent.approvals.inspect(approval['confirmation'])['action']['target'], self.active)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
with self.assertRaises(ValueError):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
launch.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_approval_dispatch_holds_existing_work_guard(self):
|
||||
attempts = []
|
||||
def launch(args):
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
return {'message': 'launched'}
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch', side_effect=launch):
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
result = agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
self.assertEqual(result['message'], 'launched')
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
self.assertEqual(self.active['id'], 'a')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
|
||||
class FinalLaunchTests(unittest.TestCase):
|
||||
"""Production delivery/cleanup with separate temporary A/B remote homes."""
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
|
||||
self.before = (server.FRAME, list(server.HOST_OPTS))
|
||||
self.stack.enter_context(mock.patch.object(server, 'LOCAL', False))
|
||||
self.stack.enter_context(mock.patch.object(server, 'LINK', None))
|
||||
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
|
||||
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
|
||||
self.stack.enter_context(mock.patch.object(server, '_touch'))
|
||||
self.stack.enter_context(mock.patch.object(server, 'ensure_master'))
|
||||
local = self.root / 'local'
|
||||
(local / 'packages').mkdir(parents=True)
|
||||
packages = []
|
||||
for n in (1, 2):
|
||||
name, data = 'test%d.pkg.tar.zst' % n, ('offline package %d' % n).encode()
|
||||
(local / 'packages' / name).write_bytes(data)
|
||||
packages.append((name, hashlib.sha256(data).hexdigest()))
|
||||
self.stack.enter_context(mock.patch.object(server, 'KDECONNECT', local))
|
||||
self.agent = server.InputAgent(packages=packages)
|
||||
self.stack.enter_context(mock.patch.object(server, '_input', self.agent))
|
||||
self.stack.callback(lambda: server.route(*self.before))
|
||||
for alias in ('target-a', 'target-b'):
|
||||
(self.root / alias).mkdir()
|
||||
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
|
||||
self.original_route = tuple([*server.SSH, server.FRAME])
|
||||
self.calls = []
|
||||
self.real_popen = subprocess.Popen
|
||||
|
||||
def remote(self, argv, **kw):
|
||||
target, command = argv[-2:]
|
||||
self.assertIn(target, ('target-a', 'target-b'))
|
||||
feed = kw.get('input')
|
||||
if isinstance(feed, str):
|
||||
feed = feed.encode()
|
||||
home = self.root / target
|
||||
proc = self.real_popen(['sh', '-c', command], cwd=home,
|
||||
env={**os.environ, 'HOME': str(home)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = proc.communicate(feed, timeout=3)
|
||||
self.calls.append({'route': tuple(argv[:-1]), 'command': command, 'input': feed})
|
||||
return subprocess.CompletedProcess(argv, proc.returncode,
|
||||
out.decode() if kw.get('text') else out,
|
||||
err.decode() if kw.get('text') else err)
|
||||
|
||||
def delivery_boundary(self, boundary, cancel=False):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
def remote(argv, **kw):
|
||||
result = self.remote(argv, **kw)
|
||||
command = argv[-1]
|
||||
hit = ((boundary == 'stamp' and 'cmp -s' in command)
|
||||
or (boundary == 'mkdir' and command.startswith('mkdir -p'))
|
||||
or (boundary == 'copy' and command.startswith('cd ')))
|
||||
if hit and not entered.is_set():
|
||||
entered.set()
|
||||
if not release.wait(3):
|
||||
raise AssertionError('delivery barrier not released')
|
||||
return result
|
||||
self.agent.launching = self.agent.generation
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen') as launch:
|
||||
worker = threading.Thread(target=self.agent._launch, args=(self.agent.generation,))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(3))
|
||||
if cancel:
|
||||
self.agent.stop()
|
||||
else:
|
||||
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
|
||||
release.set()
|
||||
worker.join(3)
|
||||
self.assertFalse(worker.is_alive())
|
||||
launch.assert_not_called()
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(3)
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
copies = [c for c in self.calls if c['command'].startswith('cd ')]
|
||||
self.assertEqual(len(copies), 1 if boundary == 'copy' else 0)
|
||||
for alias in ('target-a', 'target-b'):
|
||||
self.assertFalse(list((self.root / alias).rglob('*.part')))
|
||||
self.assertFalse(list((self.root / alias).glob('**/incoming/*')))
|
||||
self.assertIsNone(self.agent.proc)
|
||||
self.assertEqual(self.agent.status['state'], 'off')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_switch_after_stamp_cannot_deliver_to_b(self):
|
||||
self.delivery_boundary('stamp')
|
||||
|
||||
def test_switch_after_mkdir_cleans_only_a(self):
|
||||
self.delivery_boundary('mkdir')
|
||||
|
||||
def test_switch_after_first_copy_stops_next_copy_and_cleans_a(self):
|
||||
self.delivery_boundary('copy')
|
||||
|
||||
def test_cancel_after_first_copy_stops_next_copy_and_cleans_a(self):
|
||||
self.delivery_boundary('copy', cancel=True)
|
||||
|
||||
def test_deferred_no_output_cleanup_keeps_the_original_route(self):
|
||||
watching, release = threading.Event(), threading.Event()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
def stdout():
|
||||
watching.set()
|
||||
if not release.wait(3):
|
||||
raise AssertionError('watch barrier not released')
|
||||
yield from ()
|
||||
proc.stdout = stdout()
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen', return_value=proc):
|
||||
self.agent.start()
|
||||
try:
|
||||
self.assertTrue(watching.wait(3))
|
||||
self.assertEqual(server._work[0], 0, 'watch lifetime must not block device switching')
|
||||
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
|
||||
release.set()
|
||||
# The real watch completes and discard runs after route publication.
|
||||
for _ in range(150):
|
||||
if any(c['command'].startswith('rm -rf') for c in self.calls):
|
||||
break
|
||||
threading.Event().wait(.01)
|
||||
self.assertTrue(any(c['command'].startswith('rm -rf') for c in self.calls))
|
||||
finally:
|
||||
release.set()
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
self.assertFalse(list((self.root / 'target-a').glob('**/incoming/*')))
|
||||
self.assertIsNone(self.agent.proc)
|
||||
self.assertEqual(self.agent.status['state'], 'off')
|
||||
def test_package_retry_keeps_the_original_ssh_route(self):
|
||||
first, second = mock.Mock(), mock.Mock()
|
||||
first.stdout = [b'{"state":"need-packages"}\n']
|
||||
second.stdout = [b'{"state":"ready"}\n']
|
||||
first.poll.return_value = second.poll.return_value = 0
|
||||
first.wait.side_effect = lambda: server.route('target-a', ['-o', 'HostName=192.0.2.2'])
|
||||
second.wait.return_value = 0
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen', side_effect=[first, second]) as popen:
|
||||
self.agent.launching = self.agent.generation
|
||||
self.agent._launch(self.agent.generation)
|
||||
self.assertEqual(popen.call_count, 2)
|
||||
self.assertTrue(all(tuple(call.args[0][:-1]) == self.original_route for call in popen.call_args_list))
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
self.assertEqual(len([c for c in self.calls if c['command'].startswith('cd ')]), 4)
|
||||
|
||||
|
||||
|
||||
class FinalGetStartTests(unittest.TestCase):
|
||||
setUp = TargetTests.setUp
|
||||
switch = TargetTests.switch
|
||||
def handler(self, path, meant):
|
||||
handler = server.Handler.__new__(server.Handler)
|
||||
handler.command, handler.path = 'GET', path
|
||||
handler.headers = {'Host': '127.0.0.1:8123', 'X-Frame-UI': server.UI_KEY}
|
||||
if meant is not None:
|
||||
handler.headers['X-Frame-Device'] = meant
|
||||
handler.send_json, handler.send_error_json = mock.Mock(), mock.Mock()
|
||||
return handler
|
||||
|
||||
def test_stale_authenticated_get_start_is_rejected_before_start(self):
|
||||
self.active.update(id='b', name='Headset B')
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/%s?start=1' % name, 'a')
|
||||
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(handler.send_error_json.call_args.args[1], 409)
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_desktop_get_start_requires_an_intended_device(self):
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/%s?start=1' % name, None)
|
||||
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(handler.send_error_json.call_args.args[1], 400)
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_get_start_validation_and_thread_registration_are_one_reservation(self):
|
||||
attempts = []
|
||||
fresh = server.TouchAgent()
|
||||
def register():
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
with mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start', side_effect=register):
|
||||
handler = self.handler('/api/touch?start=1', 'a')
|
||||
handler.do_GET()
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
self.assertEqual(self.active['id'], 'a')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_delayed_launch_uses_the_synchronously_captured_route(self):
|
||||
pending = []
|
||||
class QueuedThread:
|
||||
def __init__(self, target, args=(), kwargs=None, **ignored):
|
||||
self.target, self.args, self.kwargs = target, args, kwargs or {}
|
||||
def start(self):
|
||||
pending.append(self)
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
try:
|
||||
for switched in (False, True):
|
||||
with self.subTest(switched=switched):
|
||||
fresh = server.TouchAgent()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
with mock.patch.object(server, '_touch', fresh), \
|
||||
mock.patch.object(server.threading, 'Thread', QueuedThread), \
|
||||
mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(fresh, '_watch', return_value=(False, True)), \
|
||||
mock.patch.object(server.subprocess, 'Popen', return_value=proc) as popen:
|
||||
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
|
||||
original = tuple([*server.SSH, server.FRAME])
|
||||
handler = self.handler('/api/touch?start=1', 'a')
|
||||
handler.do_GET()
|
||||
self.assertEqual(len(pending), 1)
|
||||
# Also change options without a generation change: this
|
||||
# proves the route was captured before the thread runs.
|
||||
server.route('target-b' if switched else 'target-a', ['-o', 'HostName=192.0.2.2'])
|
||||
thread = pending.pop()
|
||||
thread.target(*thread.args, **thread.kwargs)
|
||||
if switched:
|
||||
popen.assert_not_called()
|
||||
self.assertIsNone(fresh.proc)
|
||||
else:
|
||||
self.assertEqual(tuple(popen.call_args.args[0][:-1]), original)
|
||||
fresh.stop()
|
||||
server._live_tunnels.discard(proc)
|
||||
finally:
|
||||
server.route(*before)
|
||||
|
||||
def test_same_target_start_status_and_local_ios_remain_usable(self):
|
||||
for link, meant, query in ((self.link, 'a', '?start=1'), (self.link, 'stale', ''),
|
||||
(None, None, '?start=1')):
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name, query=query, local=link is None):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/' + name + query, meant)
|
||||
with mock.patch.object(server, 'LINK', link), mock.patch.object(server, 'LOCAL', link is None), \
|
||||
mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
self.assertEqual(start.call_count, 1 if query else 0)
|
||||
handler.send_error_json.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
|
||||
class UpdateTests(unittest.TestCase):
|
||||
"""Execute generated remote commands in a temporary home; only Steam is synthetic."""
|
||||
def exercise(self, failure, repair_failure=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
old = {'package': 'org.test.app', 'version': '1', 'label': 'Old', 'instance': 2800000001,
|
||||
'shortcut': 42, 'flatscreen': False, 'artwork': {}}
|
||||
files = {'app.apk': b'old APK', 'launch.sh': b'old launcher', 'instance.id': b'2800000001',
|
||||
'meta.json': json.dumps(old).encode(), 'shortcut.id': b'42', 'user-save': b'precious data'}
|
||||
for name, data in files.items():
|
||||
(d / name).write_bytes(data)
|
||||
(d / 'launch.sh').chmod(0o755)
|
||||
save = root / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
||||
save.parent.mkdir(parents=True)
|
||||
save.write_bytes(b'Android saved data')
|
||||
apk = root / 'new.apk'
|
||||
apk.write_bytes(b'new APK')
|
||||
info = {'package': 'org.test.app', 'version': '2', 'label': 'New', 'vr': False}
|
||||
writes = []
|
||||
repairing = [False]
|
||||
repair_failure_pending = [False]
|
||||
cleanup_attempts = []
|
||||
def ssh(cmd, input=None, **kw):
|
||||
repair_stage = None
|
||||
if repairing[0] and repair_failure_pending[0]:
|
||||
if '/meta.json.tmp' in cmd and cmd.startswith('cat >'):
|
||||
repair_stage = 'meta'
|
||||
elif '/update-recovery.json.tmp' in cmd and cmd.startswith('cat >'):
|
||||
repair_stage = 'checkpoint'
|
||||
elif cmd.startswith('rm -rf ') and '.frame-update-backup' in cmd:
|
||||
repair_stage = 'cleanup'
|
||||
if repair_failure == 'cleanup-partial' and repair_stage == 'cleanup':
|
||||
(d / '.frame-update-backup/ready').unlink()
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('partial repair cleanup')
|
||||
if repair_failure == repair_stage:
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('transient repair ' + repair_stage)
|
||||
if failure == 'cleanup' and cmd == 'rm -rf Applications/Android/org.test.app/.frame-update-backup' and not cleanup_attempts:
|
||||
cleanup_attempts.append(cmd)
|
||||
raise android.FrameError('backup cleanup unavailable')
|
||||
if repairing[0] and cmd == 'python3 -':
|
||||
return json.dumps({'icon_png': '', 'artwork': {}})
|
||||
if failure == 'rollback' and 'frame-update-backup' in cmd and ('cp -' in cmd or 'mv ' in cmd) and writes and not repairing[0]:
|
||||
raise android.FrameError('restore connection lost')
|
||||
if failure == 'meta' and '/meta.json.tmp' in cmd and not writes:
|
||||
writes.append('meta failed')
|
||||
raise android.FrameError('metadata unavailable')
|
||||
if isinstance(input, str):
|
||||
input = input.encode()
|
||||
p = subprocess.run(['sh', '-c', cmd], input=input, cwd=root,
|
||||
env={**os.environ, 'HOME': str(root)}, capture_output=True)
|
||||
if p.returncode:
|
||||
raise android.FrameError(p.stderr.decode())
|
||||
if repair_stage and repair_failure == repair_stage + '-after':
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('lost repair ' + repair_stage + ' response')
|
||||
if failure == 'meta-after' and '/meta.json.tmp' in cmd and not writes:
|
||||
writes.append('meta response lost')
|
||||
raise android.FrameError('metadata response lost')
|
||||
return p.stdout.decode()
|
||||
def copy(src, dest, **kw):
|
||||
target = root / dest
|
||||
shutil.copyfile(src, target)
|
||||
if kw.get('executable'):
|
||||
target.chmod(0o755)
|
||||
def shortcut_tool(*args, **kw):
|
||||
if args[0] == 'add':
|
||||
return '43'
|
||||
if args[0] == 'remove':
|
||||
if not repairing[0]:
|
||||
raise android.FrameError('shortcut cleanup unavailable')
|
||||
return '{}'
|
||||
raise AssertionError(args)
|
||||
def library(*a, **kw):
|
||||
if repairing[0]:
|
||||
self.assertEqual(a[1], 'Old')
|
||||
self.assertTrue(kw['vr'])
|
||||
self.assertEqual(kw['fill_only'], not (d / 'update-recovery.json').exists())
|
||||
(root / 'steam-state').write_text('old library settings')
|
||||
if repair_failure_pending[0] and repair_failure == 'library':
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('transient repair library failure')
|
||||
return {'artwork': {}, 'warnings': []}
|
||||
# A partial library mutation is externally observable even when configure fails.
|
||||
(root / 'steam-state').write_text('new library settings')
|
||||
if failure in ('library', 'rollback', 'orphan'):
|
||||
writes.append('library failed')
|
||||
raise android.FrameError('Steam unavailable after partial configure')
|
||||
return {'artwork': {}, 'warnings': []}
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_copy', side_effect=copy), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value=set() if failure == 'orphan' else {42}), \
|
||||
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool), \
|
||||
mock.patch.object(android, 'apply_library', side_effect=library):
|
||||
with self.assertRaises(android.FrameError) as error:
|
||||
android._install(str(apk), info, info['package'], True, None, None)
|
||||
if failure != 'rollback':
|
||||
for name, data in files.items():
|
||||
self.assertEqual((d / name).read_bytes(), data, name)
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
if os.name != 'nt': # Windows has no execute bit to restore
|
||||
self.assertTrue((d / 'launch.sh').stat().st_mode & 0o100)
|
||||
self.assertFalse((d / 'app.apk.part').exists())
|
||||
self.assertFalse((d / 'meta.json.tmp').exists())
|
||||
self.assertEqual((d / 'user-save').read_bytes(), b'precious data')
|
||||
self.assertEqual(save.read_bytes(), b'Android saved data')
|
||||
# Steam's API cannot guarantee a full rollback. Never hide that residual state.
|
||||
self.assertIn('repair', str(error.exception).lower())
|
||||
record = json.loads((d / 'update-recovery.json').read_text())
|
||||
self.assertIn('error', record)
|
||||
self.assertIn('repair', record)
|
||||
if failure == 'rollback':
|
||||
self.assertIn('restore connection lost', str(error.exception))
|
||||
self.assertTrue((d / '.frame-update-backup').is_dir())
|
||||
if failure == 'orphan':
|
||||
self.assertEqual(record['orphan_shortcut'], 43)
|
||||
self.assertIn('shortcut cleanup unavailable', str(error.exception))
|
||||
snapshot = {str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copying')):
|
||||
with self.assertRaises(android.FrameError):
|
||||
android._install(str(apk), info, info['package'], True, None, None)
|
||||
self.assertEqual({str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}, snapshot,
|
||||
'Retry must preserve all prior recovery evidence byte for byte')
|
||||
repairing[0] = True
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
|
||||
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool) as api, \
|
||||
mock.patch.object(android, 'apply_library', side_effect=library):
|
||||
if repair_failure:
|
||||
repair_failure_pending[0] = True
|
||||
with self.assertRaises(android.FrameError):
|
||||
android.refresh_art(info['package'], fill_only=True)
|
||||
# A later failure must retain either the ready snapshot or a
|
||||
# durable terminal checkpoint, never an unconfirmed dead end.
|
||||
if (d / 'update-recovery.json').exists():
|
||||
retry_record = json.loads((d / 'update-recovery.json').read_text())
|
||||
for key in ('error', 'rollback_error', 'orphan_shortcut', 'cleanup_error'):
|
||||
self.assertEqual(retry_record[key], record[key], key)
|
||||
self.assertTrue((d / '.frame-update-backup/ready').exists() or
|
||||
retry_record.get('repair_completed') is True)
|
||||
else:
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
self.assertEqual((d / 'app.apk').read_bytes(), files['app.apk'])
|
||||
self.assertEqual((d / 'launch.sh').read_bytes(), files['launch.sh'])
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
coherent = json.loads((d / 'meta.json').read_text())
|
||||
self.assertEqual(coherent['version'], '1')
|
||||
self.assertFalse(coherent['flatscreen'])
|
||||
repaired = android.refresh_art(info['package'], fill_only=True)
|
||||
if failure == 'orphan':
|
||||
api.assert_called_once_with('remove', '43')
|
||||
self.assertEqual(repaired['version'], '1')
|
||||
self.assertFalse(repaired['flatscreen'])
|
||||
self.assertEqual((root / 'steam-state').read_text(), 'old library settings')
|
||||
for name in ('app.apk', 'launch.sh', 'instance.id', 'shortcut.id', 'user-save'):
|
||||
self.assertEqual((d / name).read_bytes(), files[name], name)
|
||||
self.assertFalse((d / 'update-recovery.json').exists())
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
self.assertEqual(save.read_bytes(), b'Android saved data')
|
||||
return record
|
||||
|
||||
def test_late_library_failure_restores_executable_metadata_and_preserves_data(self):
|
||||
self.exercise('library')
|
||||
|
||||
def test_meta_write_failure_restores_executable_metadata_and_preserves_data(self):
|
||||
self.exercise('meta')
|
||||
|
||||
def test_missing_snapshot_cannot_claim_an_unconfirmed_rollback_is_repaired(self):
|
||||
def ssh(cmd, **kw):
|
||||
if cmd.startswith('test -f'):
|
||||
return 'yes'
|
||||
if cmd.startswith('cat ') and cmd.endswith('update-recovery.json'):
|
||||
return json.dumps({'files_restored': False, 'library_may_have_changed': True})
|
||||
if cmd == 'python3 -':
|
||||
return json.dumps({'icon_png': '', 'artwork': {}})
|
||||
return ''
|
||||
meta = {'package': 'org.test.app', 'label': 'Old', 'shortcut': 42, 'flatscreen': False}
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_meta_or_fail', return_value=meta), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, '_write_meta'), \
|
||||
mock.patch.object(android, 'apply_library', return_value={}) as library:
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
library.assert_not_called()
|
||||
|
||||
def test_retry_preserves_absent_snapshot_uncertainty_and_repair_still_refuses(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
record = {'files_restored': False, 'files_unchanged': False, 'error': 'original lost rollback',
|
||||
'library_may_have_changed': True, 'orphan_shortcut': 43}
|
||||
(d / 'update-recovery.json').write_text(json.dumps(record))
|
||||
evidence = (d / 'update-recovery.json').read_bytes()
|
||||
def ssh(cmd, input=None, **kw):
|
||||
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('must refuse before copy')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
|
||||
|
||||
def test_recovery_appearing_after_preflight_is_not_owned_by_the_new_attempt(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
meta = {'package': 'org.test.app', 'instance': 2800000001, 'shortcut': 42, 'label': 'Old'}
|
||||
(d / 'meta.json').write_text(json.dumps(meta))
|
||||
(d / 'app.apk').write_bytes(b'old APK')
|
||||
record = b'{"orphan_shortcut":43,"files_restored":false,"error":"prior failure"}'
|
||||
def prepare(*args):
|
||||
(d / 'update-recovery.json').write_bytes(record)
|
||||
(d / 'app.apk.part').write_bytes(b'prior partial evidence')
|
||||
return {}, []
|
||||
def ssh(cmd, input=None, **kw):
|
||||
if isinstance(input, str):
|
||||
input = input.encode()
|
||||
result = subprocess.run(['sh', '-c', cmd], input=input, cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=prepare), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copy')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), record)
|
||||
self.assertEqual((d / 'app.apk.part').read_bytes(), b'prior partial evidence')
|
||||
self.assertEqual((d / 'app.apk').read_bytes(), b'old APK')
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
|
||||
def test_snapshot_without_record_cannot_be_repaired_or_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
backup = d / '.frame-update-backup'
|
||||
backup.mkdir(parents=True)
|
||||
(backup / 'ready').touch()
|
||||
(backup / 'app.apk').write_bytes(b'old APK evidence')
|
||||
def ssh(cmd, input=None, **kw):
|
||||
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
self.assertFalse((d / 'update-recovery.json').exists())
|
||||
self.assertEqual((backup / 'app.apk').read_bytes(), b'old APK evidence')
|
||||
|
||||
def test_meta_write_response_loss_restores_old_metadata_too(self):
|
||||
self.exercise('meta-after')
|
||||
|
||||
def test_new_shortcut_cleanup_failure_is_recorded_and_repaired(self):
|
||||
self.exercise('orphan')
|
||||
|
||||
def test_backup_cleanup_failure_rolls_back_and_has_a_repair_path(self):
|
||||
self.exercise('cleanup')
|
||||
|
||||
def test_restore_failure_reports_and_keeps_recovery_evidence(self):
|
||||
self.exercise('rollback')
|
||||
|
||||
def test_repair_retains_recovery_until_all_stages_and_cleanup_succeed(self):
|
||||
for stage in ('library', 'meta', 'meta-after', 'checkpoint',
|
||||
'checkpoint-after', 'cleanup', 'cleanup-partial', 'cleanup-after'):
|
||||
with self.subTest(stage=stage):
|
||||
self.exercise('rollback', repair_failure=stage)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Standalone page regressions with production JavaScript and no live services."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the standalone page scripts')
|
||||
class StandaloneReview(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', str(root / 'tests/standalone_review.cjs')],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -0,0 +1,360 @@
|
||||
"""The store's Frame categories (ui/apk_sources/_categories.py): which apps go where, which are
|
||||
phone-only and hidden, and how the browse, search and library artwork use them. Offline."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, _categories, _images, fdroid, search # noqa: E402
|
||||
import test_fdroid_sources as fixtures # noqa: E402 (a module, so its tests don't run twice)
|
||||
FIXTURES, PIN, URL = fixtures.FIXTURES, fixtures.PIN, fixtures.URL
|
||||
|
||||
|
||||
def kind(**entry):
|
||||
return _categories.classify(dict({'name': 'App', 'summary': ''}, **entry))
|
||||
|
||||
|
||||
class Classify(unittest.TestCase):
|
||||
def test_vr_first_whatever_else_it_is(self):
|
||||
self.assertEqual(kind(vr=True, categories=['Phone & SMS']), {'category': 'vr', 'phone_only': False})
|
||||
|
||||
def test_fine_categories_decide(self):
|
||||
self.assertEqual(kind(categories=['Games', 'Puzzle Game'])['category'], 'games')
|
||||
self.assertEqual(kind(categories=['Games', 'Emulator'])['category'], 'emulators')
|
||||
# A chat app is in Multimedia too; the finer Social Network wins.
|
||||
self.assertEqual(kind(categories=['Multimedia', 'Internet', 'Social Network'])['category'], 'web')
|
||||
self.assertEqual(kind(categories=['Reading', 'Ebook Reader'])['category'], 'read')
|
||||
self.assertEqual(kind(categories=['Multimedia', 'Podcast'])['category'], 'watch')
|
||||
|
||||
def test_broad_categories_alone(self):
|
||||
self.assertEqual(kind(categories=['Multimedia']), {'category': 'watch', 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Writing']), {'category': 'work', 'phone_only': False})
|
||||
|
||||
def test_phone_only(self):
|
||||
for categories in (['System', 'Launcher'], ['Internet', 'VPN & Proxy'], ['Theming', 'Wallpaper'],
|
||||
['Sports & Health'], ['Connectivity'], ['Time', 'Alarm Clock'], ['Habit Tracker']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertTrue(kind(categories=categories)['phone_only'])
|
||||
self.assertTrue(kind(name='[ROOT] Night Light', categories=['Development'])['phone_only'])
|
||||
self.assertTrue(kind(name='5G Proxy Client', summary='Routes traffic')['phone_only'])
|
||||
|
||||
def test_games_and_media_keep_phone_words(self):
|
||||
# 'Battery' in a game's name or a widget in a music player doesn't hide a game or a player.
|
||||
self.assertFalse(kind(name='Widget Wars', categories=['Strategy Game'])['phone_only'])
|
||||
self.assertFalse(kind(name='Music', summary='with a home screen widget', categories=['Local Media Player'])['phone_only'])
|
||||
|
||||
def test_emulators_by_name_without_categories(self):
|
||||
self.assertEqual(kind(name='RetroArch', summary='Frontend for emulators')['category'], 'emulators')
|
||||
|
||||
def test_unknown_is_neither_hidden_nor_guessed(self):
|
||||
self.assertEqual(kind(name='Thing'), {'category': None, 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Something New']), {'category': None, 'phone_only': False})
|
||||
|
||||
def test_matches(self):
|
||||
phone = {'category': 'tools', 'phone_only': True}
|
||||
self.assertFalse(_categories.matches(phone))
|
||||
self.assertTrue(_categories.matches(phone, phone=True))
|
||||
self.assertTrue(_categories.matches({'category': 'games', 'phone_only': False}, 'games'))
|
||||
self.assertFalse(_categories.matches({'category': 'watch', 'phone_only': False}, 'games'))
|
||||
|
||||
def test_every_store_category_has_a_label(self):
|
||||
self.assertEqual({c for c, _ in _categories.CATEGORIES}, _categories.IDS)
|
||||
targets = set(_categories.FINE) | {c for c in _categories.BROAD.values() if c}
|
||||
self.assertLessEqual(targets, _categories.IDS)
|
||||
self.assertLessEqual(set(_categories.PRIORITY), _categories.IDS)
|
||||
|
||||
|
||||
class FDroidCategories(unittest.TestCase):
|
||||
setUp, fetch, add = fixtures.Repositories.setUp, fixtures.Repositories.fetch, fixtures.Repositories.add
|
||||
|
||||
def index(self, version, categories):
|
||||
raw = json.loads((FIXTURES / ('artwork-' + version + '.json')).read_text())
|
||||
if version == 'v2':
|
||||
raw['packages']['org.example.app']['metadata']['categories'] = categories
|
||||
else:
|
||||
raw['apps'][0]['categories'] = categories
|
||||
path = self.root / ('index-' + version + '.json')
|
||||
path.write_text(json.dumps(raw))
|
||||
if version == 'v1':
|
||||
fdroid._v1(path.read_bytes(), path)
|
||||
return path
|
||||
|
||||
def test_categories_survive_v1_and_v2(self):
|
||||
source = self.add()
|
||||
for version in ('v1', 'v2'):
|
||||
with self.subTest(version=version):
|
||||
apps = fdroid._reduce(self.index(version, ['Games', 'Puzzle Game', 7]), source)
|
||||
self.assertEqual(apps['org.example.app']['categories'], ['Games', 'Puzzle Game'])
|
||||
|
||||
def save(self, source, apps):
|
||||
fdroid._write(fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json'),
|
||||
{'version': fdroid.CACHE_VERSION, 'url': URL, 'fingerprint': PIN, 'apps': apps})
|
||||
|
||||
def app(self, pkg, name, updated, categories=(), banner=None):
|
||||
return {'source': 'x', 'id': pkg, 'package': pkg, 'name': name, 'summary': '', 'updated': updated,
|
||||
'categories': list(categories), 'images': {'icon': None, 'banner': banner, 'screenshots': []}}
|
||||
|
||||
def test_browse_shows_artwork_then_newest_and_filters_before_the_limit(self):
|
||||
source = self.add()
|
||||
self.save(source, {a['id']: a for a in (
|
||||
self.app('a.old', 'Aaa', '2020-01-01', ['Puzzle Game']),
|
||||
self.app('b.new', 'Bbb', '2026-01-01', ['Puzzle Game']),
|
||||
self.app('c.art', 'Ccc', '2019-01-01', ['Puzzle Game'], banner=URL + 'b.png'),
|
||||
self.app('d.sms', 'Ddd', '2026-02-01', ['Phone & SMS']))})
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=10)], ['c.art', 'd.sms', 'b.new', 'a.old'])
|
||||
keep = lambda e: _categories.matches(dict(e, **_categories.classify(e)))
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=1, keep=keep)], ['c.art'])
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, 'a', limit=10)], ['a.old', 'c.art']) # a query sorts by name
|
||||
|
||||
def test_lookup_reads_the_saved_index_only(self):
|
||||
source = self.add()
|
||||
self.assertEqual(fdroid.lookup(dict(source, id='never-loaded'), ['org.example.app']), {})
|
||||
self.save(source, {'a.b': self.app('a.b', 'A', None)})
|
||||
before = self.fetch_mock.call_count
|
||||
self.assertEqual(list(fdroid.lookup(source, ['a.b', 'missing'])), ['a.b'])
|
||||
self.assertEqual(self.fetch_mock.call_count, before)
|
||||
self.assertEqual(fdroid.lookup(dict(source, enabled=False), ['a.b']), {})
|
||||
|
||||
|
||||
def fake_source(entries, kind_='fake', lookup=None):
|
||||
mod = types.SimpleNamespace(KIND=kind_)
|
||||
mod.sources = lambda: [{'id': kind_, 'kind': kind_, 'name': kind_.title(), 'url': 'https://example.org/',
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'}]
|
||||
mod.search = lambda source, query, limit=50: [dict(e) for e in entries][:limit]
|
||||
mod.details = lambda source, entry_id: next(dict(e) for e in entries if e['id'] == entry_id)
|
||||
if lookup is not None:
|
||||
mod.lookup = lookup
|
||||
return mod
|
||||
|
||||
|
||||
ENTRY = {'source': 'fake', 'package': None, 'summary': '', 'free': True, 'downloadable': True,
|
||||
'min_sdk': 24, 'abis': ['arm64-v8a'], 'vr': None}
|
||||
|
||||
|
||||
class Search(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
for state in (search._running, search._pending, search._status):
|
||||
state.clear()
|
||||
self.addCleanup(state.clear)
|
||||
|
||||
def run_search(self, entries, **kwargs):
|
||||
with patch.object(search, 'modules', return_value=([fake_source(entries)], [])):
|
||||
return search.search(**kwargs)
|
||||
|
||||
def test_category_and_phone_filters(self):
|
||||
entries = [dict(ENTRY, id='g', name='Game', categories=['Puzzle Game']),
|
||||
dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS']),
|
||||
dict(ENTRY, id='v', name='Video', categories=['Local Media Player'])]
|
||||
result = self.run_search(entries)
|
||||
self.assertEqual(sorted(a['name'] for a in result['apps']), ['Game', 'Video'])
|
||||
self.assertEqual({a['name']: a['category'] for a in result['apps']}, {'Game': 'games', 'Video': 'watch'})
|
||||
self.assertIn({'id': 'emulators', 'label': 'Emulators'}, result['categories'])
|
||||
self.assertEqual([a['name'] for a in self.run_search(entries, category='games')['apps']], ['Game'])
|
||||
self.assertEqual(len(self.run_search(entries, phone=True)['apps']), 3)
|
||||
with self.assertRaisesRegex(SourceError, 'Unknown category'):
|
||||
self.run_search(entries, category='phones')
|
||||
|
||||
def test_listing_offers_three_screenshots_details_all(self):
|
||||
shots = ['https://example.org/%d.png' % i for i in range(8)]
|
||||
entry = dict(ENTRY, id='g', name='Game', images={'icon': None, 'banner': None, 'screenshots': shots})
|
||||
listed = self.run_search([entry])['apps'][0]['offers'][0]
|
||||
self.assertEqual(len(listed['artwork']['screenshots']), 3)
|
||||
self.assertEqual(len(search.decorate(entry)['artwork']['screenshots']), 8)
|
||||
|
||||
def test_browse_asks_sources_for_more_than_a_search(self):
|
||||
seen = []
|
||||
mod = fake_source([])
|
||||
mod.search = lambda source, query, limit=50: seen.append((query, limit)) or []
|
||||
with patch.object(search, 'modules', return_value=([mod], [])):
|
||||
search.search('')
|
||||
search.search('chess')
|
||||
self.assertEqual(seen, [('', search.BROWSE_LIMIT), ('chess', 50)])
|
||||
|
||||
|
||||
class LibraryArtwork(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_by_package_from_saved_indexes_then_by_name_for_curated_apps(self):
|
||||
icon = {'images': {'icon': 'https://example.org/icon.png', 'banner': None, 'screenshots': []}}
|
||||
repo = fake_source([], 'fdroid', lookup=lambda source, packages: {
|
||||
p: dict(icon, name='x') for p in packages if p == 'org.antennapod'})
|
||||
github = fake_source([dict(icon, id='icosa/open-brush', name='Open Brush')], 'github')
|
||||
bare = fake_source([dict(icon, id='x', name='Elsewhere', package='org.elsewhere')], 'itch')
|
||||
apps = [{'package': 'org.antennapod', 'label': 'AntennaPod'}, {'package': 'com.Icosa.OpenBrush', 'label': 'Open Brush'},
|
||||
{'package': 'org.elsewhere', 'label': 'Elsewhere'}, {'package': 'org.none', 'label': 'None'}]
|
||||
with patch.object(search, 'modules', return_value=([repo, github, bare], [])):
|
||||
found = search.artwork_for(apps)
|
||||
self.assertEqual(sorted(found), ['com.Icosa.OpenBrush', 'org.antennapod'])
|
||||
self.assertTrue(found['org.antennapod']['icon'].startswith('/source-image/'))
|
||||
|
||||
def test_a_failing_source_is_skipped(self):
|
||||
def broken(source, packages):
|
||||
raise SourceError('no')
|
||||
with patch.object(search, 'modules', return_value=([fake_source([], 'fdroid', lookup=broken)], [])):
|
||||
self.assertEqual(search.artwork_for([{'package': 'a.b', 'label': 'A'}]), {})
|
||||
|
||||
|
||||
class ImageHandles(unittest.TestCase):
|
||||
def test_same_url_same_handle_without_scanning(self):
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
a = _images.register('https://example.org/a.png')
|
||||
self.assertEqual(_images.register('https://example.org/a.png'), a)
|
||||
self.assertNotEqual(_images.register('https://example.org/b.png'), a)
|
||||
|
||||
|
||||
|
||||
class Route(unittest.TestCase):
|
||||
def test_search_filters_are_checked(self):
|
||||
import server
|
||||
with patch.object(server.apk_search, 'search', return_value={'apps': []}) as run:
|
||||
server.source_search('q=&category=games&phone=1')
|
||||
self.assertEqual(run.call_args.kwargs['category'], 'games')
|
||||
self.assertIs(run.call_args.kwargs['phone'], True)
|
||||
server.source_search('q=chess')
|
||||
self.assertEqual((run.call_args.kwargs['category'], run.call_args.kwargs['phone']), (None, False))
|
||||
for bad in ('phone=maybe', 'category=' + 'x' * 41):
|
||||
with self.subTest(bad=bad), self.assertRaises(server.Failure):
|
||||
server.source_search(bad)
|
||||
|
||||
|
||||
|
||||
class ReviewCases(unittest.TestCase):
|
||||
"""Real index entries (October 2026) that the first rules got wrong."""
|
||||
def test_useful_apps_with_a_phone_side_stay(self):
|
||||
for categories, category in ((['Connectivity', 'Email', 'Internet', 'Writing'], 'web'), # Thunderbird
|
||||
(['Connectivity', 'Internet', 'Multimedia', 'Podcast'], 'watch'), # FocusPodcast
|
||||
(['Contact', 'Email'], 'web'),
|
||||
(['Cloud Storage & File Sync', 'File Manager', 'System'], 'tools')):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertEqual(kind(categories=categories), {'category': category, 'phone_only': False})
|
||||
|
||||
def test_phone_side_alone_or_with_a_broad_category_is_hidden(self):
|
||||
for categories in (['Internet', 'VPN & Proxy'], ['App Manager', 'Development', 'System'], ['Navigation']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertTrue(kind(categories=categories)['phone_only'])
|
||||
self.assertTrue(kind(categories=['Launcher', 'Games'])['phone_only']) # system ones always
|
||||
|
||||
def test_no_root_needed_is_not_a_root_tool(self):
|
||||
for summary in ('Linux on Android, no root required', 'Linux Without Root Access', 'SSH server without root',
|
||||
'Works on non-rooted phones', 'Rootless and simple'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertFalse(kind(summary=summary, categories=['Development'])['phone_only'])
|
||||
self.assertTrue(kind(summary='Needs root to tune the kernel', categories=['Development'])['phone_only'])
|
||||
|
||||
def test_izzyondroid_names(self):
|
||||
self.assertEqual(kind(categories=['Office'])['category'], 'work')
|
||||
self.assertTrue(kind(categories=['Xposed'])['phone_only'])
|
||||
|
||||
|
||||
|
||||
class ReviewCasesTwo(unittest.TestCase):
|
||||
"""Round two of real entries (October 2026)."""
|
||||
def test_messengers_with_phone_or_push_stay(self):
|
||||
for categories in (['Internet', 'Messaging', 'Phone & SMS'], ['Internet', 'Messaging', 'Push'],
|
||||
['Connectivity', 'Messaging', 'Phone & SMS', 'Voice & Video Chat']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertEqual(kind(categories=categories), {'category': 'web', 'phone_only': False})
|
||||
|
||||
def test_broad_only_sources_can_escape_a_phone_side(self):
|
||||
self.assertEqual(kind(categories=['System', 'Development']), {'category': 'tools', 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Connectivity', 'Internet']), {'category': 'web', 'phone_only': False})
|
||||
self.assertTrue(kind(categories=['VPN & Proxy', 'Internet'])['phone_only']) # it has finer ones, none useful
|
||||
|
||||
def test_summary_features_dont_make_a_phone_app(self):
|
||||
self.assertFalse(kind(name='OSS Weather', summary='Forecasts with home screen widgets', categories=['Weather'])['phone_only'])
|
||||
self.assertFalse(kind(name='Digital Calculator', summary='Hardware keyboard support', categories=['Calculator', 'System'])['phone_only'])
|
||||
self.assertTrue(kind(name='Clock Widget', categories=['Clock'])['phone_only']) # but the name says what it is
|
||||
|
||||
def test_saying_root_isnt_needed(self):
|
||||
for summary in ('VNC server app that does not require root privileges', "Backups that don't need root access",
|
||||
"It doesn't need root", 'Linux without root', 'Works on non-rooted phones', 'Rootless'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertFalse(kind(summary=summary, categories=['Remote Access'])['phone_only'])
|
||||
for summary in ('Requires root', 'Needs root access to change the kernel', 'For rooted devices'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertTrue(kind(summary=summary, categories=['Remote Access'])['phone_only'])
|
||||
|
||||
def test_hardware_the_frame_lacks(self):
|
||||
self.assertTrue(kind(name='IR Blaster Remote', categories=['Multimedia', 'Remote Controller', 'System'])['phone_only'])
|
||||
self.assertTrue(kind(name='Wear Sync for Breezy', summary='Weather on your Wear OS watch', categories=['Connectivity', 'Weather'])['phone_only'])
|
||||
|
||||
|
||||
class SearchFindsPhoneApps(unittest.TestCase):
|
||||
setUp, run_search = Search.setUp, Search.run_search
|
||||
|
||||
def test_a_search_finds_what_browsing_hides(self):
|
||||
entries = [dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS'])]
|
||||
self.assertEqual(self.run_search(entries)['apps'], [])
|
||||
found = self.run_search(entries, query='texts')['apps']
|
||||
self.assertEqual([(a['name'], a['offers'][0]['phone_only']) for a in found], [('Texts', True)])
|
||||
|
||||
|
||||
|
||||
class ArtworkLookups(unittest.TestCase):
|
||||
"""A page of cards asks for dozens of images at once; they mustn't fail for want of a lookup slot."""
|
||||
def setUp(self):
|
||||
_images._dns.clear()
|
||||
self.addCleanup(_images._dns.clear)
|
||||
|
||||
def test_public_answers_are_kept_private_ones_are_not(self):
|
||||
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
|
||||
with patch.object(_images.socket, 'getaddrinfo', return_value=public) as lookup:
|
||||
for _ in range(3):
|
||||
self.assertEqual(_images._resolve('example.org', 443, 5), public)
|
||||
self.assertEqual(lookup.call_count, 1)
|
||||
private = [(2, 1, 6, '', ('10.0.0.5', 443))]
|
||||
with patch.object(_images.socket, 'getaddrinfo', return_value=private) as lookup:
|
||||
_images._resolve('inside.example', 443, 5)
|
||||
_images._resolve('inside.example', 443, 5)
|
||||
self.assertEqual(lookup.call_count, 2)
|
||||
|
||||
def test_waiting_for_a_slot_counts_against_the_budget(self):
|
||||
# Sixty milliseconds pass while a slot is busy. A still-running lookup
|
||||
# gets only the remaining forty, regardless of the test host's load.
|
||||
with patch.object(_images, '_resolvers') as slots, \
|
||||
patch.object(_images.time, 'monotonic', side_effect=[100, 100.06]), \
|
||||
patch.object(_images.threading, 'Thread') as worker:
|
||||
slots.acquire.return_value = True
|
||||
with self.assertRaises(SourceError):
|
||||
_images._resolve('slow.example', 443, 0.1)
|
||||
slots.acquire.assert_called_once_with(timeout=0.1)
|
||||
self.assertAlmostEqual(worker.return_value.join.call_args.args[0], 0.04)
|
||||
|
||||
def test_many_at_once_wait_for_a_slot(self):
|
||||
import threading, time
|
||||
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
|
||||
def slow(*a, **k):
|
||||
time.sleep(0.05)
|
||||
return public
|
||||
errors = []
|
||||
def one(i):
|
||||
try:
|
||||
_images._resolve('host%d.example' % i, 443, 5)
|
||||
except SourceError as e:
|
||||
errors.append(e)
|
||||
with patch.object(_images.socket, 'getaddrinfo', side_effect=slow):
|
||||
threads = [threading.Thread(target=one, args=(i,)) for i in range(12)]
|
||||
[t.start() for t in threads]
|
||||
[t.join() for t in threads]
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Production client race/error regressions, without any headset or browser writes."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page functions')
|
||||
class UIReview(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/ui_review.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Production navigation and app actions, without a browser or headset."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises shared page behavior')
|
||||
class Workspace(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/ui_workspace.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -394,7 +394,7 @@ class ServerJobs(unittest.TestCase):
|
||||
places = s._tmp_places()
|
||||
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
|
||||
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
|
||||
s.apk_fdroid.TMP_PREFIX})
|
||||
s.apk_fdroid.TMP_PREFIX, s.ANDROID_BACKUP_TMP})
|
||||
for folder, prefix in places:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), vm = require('node:vm');
|
||||
const {workspaceFixture, html} = require('./workspace_fixture.cjs');
|
||||
const store = fs.readFileSync(require.resolve('../ui/store.html'),'utf8');
|
||||
const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
|
||||
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
|
||||
function connectionFixture() {
|
||||
const f=workspaceFixture(), c=f.context;
|
||||
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
|
||||
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
c.window.connDevice='a';
|
||||
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
|
||||
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
|
||||
return f;
|
||||
}
|
||||
test('helper starts and status reads name the headset shown when requested',async()=>{
|
||||
const f=connectionFixture(),c=f.context,requests=[];
|
||||
c.fetch=async(path,opts)=>{requests.push({path,opts});return {ok:true,json:async()=>({state:'ready'})};};
|
||||
await f.run("api('/api/input?start=1')");
|
||||
await f.run("api('/api/touch?start=1')");
|
||||
c.window.connDevice='b';
|
||||
await f.run("api('/api/input')");
|
||||
assert.deepEqual(requests.map(r=>r.opts.headers['X-Frame-Device']),['a','a','b']);
|
||||
assert.ok(requests.every(r=>r.opts.method===undefined&&r.opts.body===undefined));
|
||||
delete c.window.connDevice; // A local iOS server has one fixed SSH route.
|
||||
await f.run("api('/api/touch?start=1')");
|
||||
assert.equal(requests[3].opts.headers['X-Frame-Device'],undefined);
|
||||
});
|
||||
test('a late input failure cannot replay A keystrokes on B',async()=>{
|
||||
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
|
||||
Object.assign(c,{pad:{state:'ready',queue:[],sending:false},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
|
||||
f.run(pick(html,'const isMove = e =>','function padKey('));f.run('padSend({key:"for A"})');
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.pad.state,'off');assert.equal(c.pad.queue.length,0);
|
||||
rejectA(Error('lost A'));await tick();c.pad.state='ready';f.run('padSend({key:"for B"})');await tick();
|
||||
assert.equal(writes.length,2);assert.equal(writes[1].device,'b');assert.equal(writes[1].events[0].key,'for B');
|
||||
});
|
||||
test('a device switch discards completed install results and the open detail',()=>{
|
||||
const f=connectionFixture(),c=f.context;
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{checked:false,value:'',textContent:'',children:[]});return els.get(id);};let pending=[],sent;
|
||||
const c={$ ,api:(path,body)=>path==='/api/report/preview'?new Promise(resolve=>pending.push({body,resolve})):Promise.resolve({}),log(){},renderContact(){}};vm.createContext(c);
|
||||
vm.runInContext(pick(html,'const bug = {','function openBugReport()'),c);
|
||||
vm.runInContext(pick(html,'$("bugForm").onsubmit =','if (window.frameApp && window.frameApp.onReportProblem)'),c);
|
||||
$('bugForm').reportValidity=()=>true;$('bugDiag').checked=true;$('bugLogs').checked=true;
|
||||
const a=vm.runInContext('loadBugPreview()',c);$('bugLogs').checked=false;const b=vm.runInContext('loadBugPreview()',c);
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
|
||||
pending[1].resolve({text:'safe summary'});await b;pending[0].resolve({text:'SECRET SERVER LOG'});await a;
|
||||
c.api=(path,body)=>{if(path==='/api/report')sent=body;return Promise.resolve({id:'stub',message:'stub'});};
|
||||
await $('bugForm').onsubmit({preventDefault(){}});assert.equal(sent.diagnostics,'safe summary');
|
||||
$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/safe summary|SECRET/);
|
||||
$('bugDiag').checked=true;c.api=(path,body)=>new Promise(resolve=>pending.push({body,resolve}));
|
||||
const late=vm.runInContext('loadBugPreview()',c);$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
|
||||
pending[2].resolve({text:'LATE SECRET'});await late;
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
|
||||
});
|
||||
test('clearing Steam search invalidates a pending query and pending country lookup',async()=>{
|
||||
const el={value:'Portal'},gm={country:'US',src:'store',store:[],storeQ:null,seq:0,shownBySource:{}};let resolve;
|
||||
const c={$:()=>el,gm,api:()=>new Promise(r=>resolve=r),renderChips(){},filterGames(){},encodeURIComponent};vm.createContext(c);
|
||||
vm.runInContext(pick(html,'async function searchStore() {','function setGameSource('),c);
|
||||
const a=vm.runInContext('searchStore()',c);el.value='';await vm.runInContext('searchStore()',c);resolve({results:[{name:'Portal'}]});await a;
|
||||
assert.equal(gm.storeQ,null);assert.equal(gm.store.length,0);
|
||||
});
|
||||
test('failed installed reads remain unknown or stale with retry, never successful empty',async()=>{
|
||||
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{textContent:'',innerHTML:'',setAttribute(){}});return els.get(id);};
|
||||
const st={installed:new Map(),needsData:new Set(),jobs:new Map(),view:'installed',q:'',loading:false,apps:[],page:0,installedState:'unknown',installedError:'',installedRequest:0};
|
||||
const c={$ ,st,api:async()=>{throw Error('offline');},toast(){},document:{querySelectorAll:()=>[]},PER_PAGE:8,catLabel:x=>x,esc:x=>x};vm.createContext(c);
|
||||
vm.runInContext(pick(store,'async function loadInstalled() {','async function search('),c);vm.runInContext(pick(store,'function list() {','function card('),c);vm.runInContext(pick(store,'function render(error) {','function turn('),c);
|
||||
await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.doesNotMatch($('content').innerHTML,/Nothing installed yet/);assert.match($('content').innerHTML,/data-retry-installed/);
|
||||
st.installed.set('app',{package:'app',label:'Cached App'});st.installedState='ready';
|
||||
c.card=()=>'<button>Cached App</button>';await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.match($('content').innerHTML,/Cached App/);assert.match($('content').innerHTML,/data-retry-installed/);
|
||||
c.api=async()=>({apps:[]});await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.match($('content').innerHTML,/Nothing installed yet/);assert.doesNotMatch($('content').innerHTML,/data-retry-installed/);
|
||||
});
|
||||
|
||||
test('capture control discards old releases, timers and readiness on a target switch',async()=>{
|
||||
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
|
||||
Object.assign(c,{ctrl:{on:true,queue:[],sending:false,state:'ready',held:new Set(['left']),keys:new Set([30]),pointers:new Map(),g:null,rel:[1,2]},desk:{panels:[{window:4}],focus:'old',shown:'old',loaded:true},cancelAnimationFrame(){},ctrlShow(){},ctrlAim:e=>e,
|
||||
pad:{queue:[],state:'off'},padReset(){},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
|
||||
f.run(pick(html,'function ctrlReset() {','// Pointer moves:'));
|
||||
f.run('ctrlSend([{key:30,down:false}])');
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.ctrl.on,false);assert.equal(c.ctrl.state,'off');assert.equal(c.ctrl.held.size,0);assert.equal(c.desk.panels.length,0);
|
||||
rejectA(Error('lost A'));await tick();
|
||||
assert.equal(c.ctrl.queue.length,0);assert.equal(c.ctrl.retryAt,0);assert.equal(writes.length,1);
|
||||
});
|
||||
|
||||
test('late installation completion cannot populate the new headset detail',async()=>{
|
||||
const f=workspaceFixture(),c=f.context,offer={source:'catalog',id:'one',name:'One',package:'app.one',downloadable:true};let finish,progress,refreshes=0;
|
||||
Object.assign(c,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',loadAndroid:()=>refreshes++,
|
||||
runJob:async(_label,_key,start,update)=>{progress=update;await start();return new Promise(resolve=>finish=resolve);},api:async()=>({job:'a'})});
|
||||
f.run(pick(html,'function updateInstall() {','async function openStoreApp('));f.run(pick(html,'async function installStoreApp() {','async function changeSource('));
|
||||
const pending=f.run('installStoreApp()');await tick();
|
||||
c.devGen++;c.sourceState.jobs.clear();f.el('detailInstall').textContent='New headset';
|
||||
progress({stage:'Installing',percent:90});finish({package:'app.one',game_data:true});await pending;
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(f.el('detailInstall').textContent,'New headset');assert.equal(refreshes,0);
|
||||
});
|
||||
|
||||
test('late input startup and touch poll responses cannot re-enable the old target',async()=>{
|
||||
const f=workspaceFixture(),c=f.context;let finish;
|
||||
Object.assign(c,{pad:{state:'off'},clientPreferences:{set(){}},padShow:s=>{c.pad.state=s.state;},api:()=>new Promise(resolve=>finish=resolve)});
|
||||
f.run(pick(html,'async function padStart() {','$("padOn").onclick'));
|
||||
const start=f.run('padStart()');c.devGen++;c.pad.state='off';finish({state:'ready'});await start;assert.equal(c.pad.state,'off');
|
||||
Object.assign(c,{ctrl:{on:true,state:'off'},ctrlShow(){},ctrlFlush(){throw Error('Must not flush stale input');}});
|
||||
f.run(pick(html,'async function ctrlPoll(', '$("viewer").tabIndex'));
|
||||
const poll=f.run('ctrlPoll(0)');c.devGen++;c.ctrl.on=false;finish({state:'ready'});await poll;assert.equal(c.ctrl.state,'off');
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
const deferred=()=>{let resolve,reject;const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject};};
|
||||
function installFixture() {
|
||||
const f=workspaceFixture();
|
||||
Object.assign(f.context,{wi:{queue:[]},wiNext(){f.calls.push(['review']);},sendFiles:files=>f.calls.push(['files',files])});
|
||||
const start=html.indexOf('function openInstallChooser()'),end=html.indexOf('function renderHome(',start);
|
||||
f.run(html.slice(start,end));return f;
|
||||
}
|
||||
test('Add / Install retains cancellation and hands chosen files to the existing transfer flow',()=>{
|
||||
const f=installFixture(),input=f.el('installFileInput');let clicks=0;
|
||||
input.click=()=>clicks++;f.run('openInstallChooser()');f.el('installChooseFile').onclick();assert.equal(clicks,1);
|
||||
input.files=[];input.onchange();assert.equal(f.el('installChooser').open,true);assert.equal(f.calls.length,0);
|
||||
const files=[{name:'game.zip'},{name:'player.apk'}];input.files=files;input.value='chosen';input.onchange();
|
||||
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.location.hash,'#transfer');assert.equal(input.value,'');
|
||||
assert.deepEqual(Array.from(f.calls[0][1]),files);
|
||||
});
|
||||
test('chooser destinations reapply explicit views when their hash is already active',()=>{
|
||||
const cases=[['#appStore',"selectApps('installed')",f=>assert.equal(f.el('appStore').hidden,false)],
|
||||
['#linuxApps',"selectApps('browse')",f=>assert.equal(f.el('appsLinux').hidden,false)],
|
||||
['#getgames',"selectLibrary('all','installed')",f=>assert.equal(f.context.gm.src,'owned')],
|
||||
['#steamStore',"selectLibrary('all','owned')",f=>assert.equal(f.context.gm.src,'store')]];
|
||||
for(const [hash,change,verify] of cases){
|
||||
const f=installFixture();f.navigate(hash);f.run(change);f.run('openInstallChooser()');let prevented=false;
|
||||
f.el('installChooser').onclick({target:{closest:()=>({getAttribute:()=>hash})},preventDefault(){prevented=true;}});
|
||||
assert.equal(prevented,true,hash);assert.equal(f.el('installChooser').open,false);verify(f);
|
||||
}
|
||||
});
|
||||
test('download links accept supported direct and manifest forms and reject malformed handoffs',()=>{
|
||||
const f=installFixture();
|
||||
for(const [raw,expected] of [
|
||||
[' https://example.com/game.zip ',{kind:'url',target:'https://example.com/game.zip'}],
|
||||
['frame-control://install?url=https%3A%2F%2Fexample.com%2Fapp.apk',{kind:'url',target:'https://example.com/app.apk'}],
|
||||
['frame-control://install?manifest=https%3A%2F%2Fexample.com%2Fgame.json',{kind:'manifest',target:'https://example.com/game.json'}]
|
||||
]){f.context.raw=raw;assert.deepEqual(JSON.parse(JSON.stringify(f.run('installLinkRequest(raw)'))),expected);}
|
||||
for(const raw of ['file:///tmp/app.apk','javascript:alert(1)','https://user:secret@example.com/a.apk',
|
||||
'frame-control://other?url=https://example.com/a.apk','frame-control://install/path?url=https://example.com/a.apk',
|
||||
'frame-control://install?url=https://example.com/a.apk&manifest=https://example.com/a.json',
|
||||
'frame-control://install?url=https://example.com/a.apk&url=https://example.com/b.apk',
|
||||
'frame-control://install?manifest=garbage','not a URL','https://example.com/'+ 'x'.repeat(2050)]){
|
||||
f.context.raw=raw;assert.throws(()=>f.run('installLinkRequest(raw)'));
|
||||
}
|
||||
});
|
||||
test('link form queues a review only, with inline errors and a bounded waiting queue',()=>{
|
||||
const f=installFixture(),submit=()=>f.el('installLinkForm').onsubmit({preventDefault(){}});
|
||||
f.run('openInstallChooser()');f.el('installUrl').value='invalid';submit();
|
||||
assert.equal(f.el('installChooser').open,true);assert.equal(f.el('installLinkError').hidden,false);assert.equal(f.context.wi.queue.length,0);assert.equal(f.calls.length,0);
|
||||
f.el('installUrl').value='https://example.com/game.zip';submit();
|
||||
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.wi.queue.length,1);assert.deepEqual(f.calls,[['review']]);
|
||||
f.context.wi.queue=Array(5).fill({kind:'url',target:'https://example.com/game.zip'});f.run('openInstallChooser()');submit();
|
||||
assert.equal(f.el('installChooser').open,true);assert.equal(f.context.wi.queue.length,5);assert.match(f.el('installLinkError').textContent,/Finish or close/);
|
||||
});
|
||||
test('link review checks metadata but starts no download before the existing confirmation',async()=>{
|
||||
const f=installFixture(),writes=[];
|
||||
Object.assign(f.context,{log(){},gb:n=>String(n),api:async(path,body)=>{writes.push({path,body});return {id:'plan-1',name:'Game',host:'example.com',linkHost:'example.com',file:'game.zip',kindLabel:'PC game',size:100};}});
|
||||
f.el('wiGo').focus=()=>{};f.el('wiDlg').addEventListener=()=>{};
|
||||
const start=html.indexOf('const wi ='),end=html.indexOf('async function wiPoll(',start);f.run(html.slice(start,end));
|
||||
f.run("wi.queue.push({kind:'url',target:'https://example.com/game.zip'})");await f.run('wiNext()');
|
||||
assert.equal(writes.length,1);assert.equal(writes[0].path,'/api/webinstall/check');assert.equal(f.el('wiDlg').open,true);assert.equal(f.el('wiGo').disabled,false);
|
||||
const cancel=html.indexOf('$("wiCancel").onclick = async () =>');f.run(html.slice(cancel,html.indexOf('// Escape',cancel)));
|
||||
await f.el('wiCancel').onclick();assert.equal(f.el('wiDlg').open,false);assert.equal(writes.length,1);
|
||||
});
|
||||
test('catalog installs retain progress, failure retry and installed-app handoff',async()=>{
|
||||
const f=workspaceFixture(),offer={source:'fdroid',id:'org.player',package:'org.player',name:'Player',downloadable:true,version_code:42},writes=[];
|
||||
let success=false,refreshes=0,opened;
|
||||
Object.assign(f.context,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',openInstalledApp:pkg=>opened=pkg,
|
||||
api:async(path,body)=>{writes.push({path,body});return {job:'job-1'};},loadAndroid:()=>refreshes++,
|
||||
runJob:async(label,key,start,progress)=>{await start();progress({stage:'Installing',percent:75});assert.equal(f.el('detailProgress').value,75);return success?{package:'org.player'}:undefined;}});
|
||||
const start=html.indexOf('function updateInstall()'),end=html.indexOf('async function openStoreApp(',start);f.run(html.slice(start,end));
|
||||
const install=html.indexOf('async function installStoreApp()');f.run(html.slice(install,html.indexOf('async function changeSource(',install)));
|
||||
await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Try again');assert.match(f.el('detailInstallNote').textContent,/couldn't finish/);
|
||||
success=true;await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Open in Steam');assert.equal(refreshes,1);
|
||||
assert.equal(writes.length,2);assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/sources/install',body:{source:'fdroid',id:'org.player',version_code:42}});
|
||||
f.context.sourceState.jobs.clear();f.context.androidApps=[{package:'org.player'}];f.el('appDetail').showModal();f.run('updateInstall()');
|
||||
assert.equal(f.el('detailInstall').textContent,'View installed app');await f.run('installStoreApp()');assert.equal(opened,'org.player');assert.equal(writes.length,2);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('Steam actions distinguish owned installation, installed play and non-owned store handoff',()=>{
|
||||
const f=workspaceFixture();
|
||||
f.context.gm.byId=new Map([[1,{installed:false}],[2,{installed:true}]]);
|
||||
const start=html.indexOf('function gameButtons(');f.run(html.slice(start,html.indexOf('function moreGames(',start)));
|
||||
assert.match(f.run("gameButtons(1,'Owned',false)"),/data-gm="install"/);
|
||||
assert.match(f.run("gameButtons(2,'Installed',false)"),/data-launch="2"/);
|
||||
for(const free of [false,true]){const output=f.run(`gameButtons(3,'Store',${free})`);assert.match(output,free?/Get on Steam/:/Buy on Steam/);assert.doesNotMatch(output,/data-gm="install"/);}
|
||||
f.context.gm.dl={appid:1,percent:30};assert.match(f.run("gameButtons(1,'Owned',false)"),/Downloading 30%/);
|
||||
});
|
||||
test('Apps Games Tools Settings expose installed, browse, ownership and intentional legacy routes',()=>{
|
||||
const f=workspaceFixture();
|
||||
assert.deepEqual(f.links.map(a=>a.getAttribute()),['#apps','#games','#tools','#settings']);
|
||||
f.navigate('');assert.equal(f.activePage,'apps');assert.equal(f.el('androidLibrary').hidden,false);assert.equal(f.el('appStore').hidden,true);
|
||||
f.navigate('#games');assert.equal(f.activePage,'games');assert.equal(f.el('getgames').hidden,true);assert.equal(f.el('library').hidden,false);assert.equal(f.el('sideloaded').hidden,false);
|
||||
f.navigate('#getgames');assert.equal(f.el('getgames').hidden,false);assert.equal(f.el('library').hidden,true);assert.equal(f.context.gm.src,'owned');
|
||||
f.navigate('#steamStore');assert.equal(f.context.gm.src,'store');assert.equal(f.el('getgames').hidden,false);
|
||||
f.navigate('#appStore');assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('androidLibrary').hidden,true);
|
||||
f.navigate('#linuxApps');assert.equal(f.activePage,'apps');assert.equal(f.el('appsLinux').hidden,false);
|
||||
f.navigate('#androidLibrary');assert.equal(f.el('appsLinux').hidden,true);assert.equal(f.el('androidLibrary').hidden,false);
|
||||
f.navigate('#display');assert.equal(f.activePage,'settings');assert.equal(f.el('androidSettings').hidden,false);assert.equal(f.el('settingsDirectory').hidden,true);
|
||||
f.navigate('#settings');assert.equal(f.el('settingsDirectory').hidden,false);assert.equal(f.el('settingsWorkspace').hidden,true);
|
||||
for(const [route,page,primary] of [['#devices','devices','settings'],['#control','control','settings'],['#shots','capture','tools'],['#view','capture','tools'],['#pad','tools','tools'],['#comfort','tools','tools'],['#library','games','games'],['#discover','apps','apps'],['#privacy','settings','settings']]){
|
||||
f.navigate(route);assert.equal(f.activePage,page,route);assert.equal(f.run('primaryPage(page)'),primary,route);
|
||||
}
|
||||
});
|
||||
test('app views and game scopes restore separate scroll positions, queries and filters',()=>{
|
||||
const f=workspaceFixture();f.navigate('#appStore');f.el('sourceQ').value='video';f.scroll(480);
|
||||
f.run("selectApps('installed')");f.el('libQ').value='player';f.scroll(220);
|
||||
f.navigate('#games');f.scroll(160);f.navigate('#apps');assert.equal(f.context.window.scrollY,220);
|
||||
f.run("selectApps('browse')");assert.equal(f.context.window.scrollY,480);assert.equal(f.el('sourceQ').value,'video');assert.equal(f.el('libQ').value,'player');
|
||||
f.navigate('#games');assert.equal(f.context.window.scrollY,160);
|
||||
f.run("selectLibrary('steam')");f.scroll(600);f.run("selectLibrary('sideloaded')");f.scroll(80);f.run("selectLibrary('steam')");assert.equal(f.context.window.scrollY,600);
|
||||
f.context.location.hash='#apps';f.scroll(0);f.run('showPage()');f.navigate('#games');assert.equal(f.context.window.scrollY,600);
|
||||
});
|
||||
test('Android browse opens Apps Browse after visiting the Steam store',()=>{
|
||||
const f=workspaceFixture();f.navigate('#steamStore');f.navigate('#androidLibrary');
|
||||
const start=html.indexOf('function showAndroidTab(tab)'),end=html.indexOf("document.querySelectorAll('[data-store-tab]')",start);
|
||||
f.run(html.slice(start,end));f.run("showAndroidTab('browse')");f.run('showPage()');
|
||||
assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);
|
||||
});
|
||||
test('empty installed library opens Browse even when its route is already in the address bar',()=>{
|
||||
const f=workspaceFixture();let click;
|
||||
f.context.libState={q:'',filter:'all'};f.el('andApps').addEventListener=(name,fn)=>click=fn;
|
||||
const helper=html.indexOf('function showAndroidTab(tab)');
|
||||
f.run(html.slice(helper,html.indexOf("document.querySelectorAll('[data-store-tab]')",helper)));
|
||||
const start=html.indexOf('function renderLibrary()');
|
||||
f.run(html.slice(start,html.indexOf('document.addEventListener("click",',start)));
|
||||
f.navigate('#appStore');f.run("selectApps('installed');renderLibrary()");
|
||||
assert.match(f.el('andApps').innerHTML,/data-store-go="browse"/);assert.equal(f.el('appStore').hidden,true);
|
||||
click({target:{closest:()=>({dataset:{storeGo:'browse'}})}});
|
||||
assert.equal(f.context.location.hash,'#appStore');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('appsInstalled').hidden,true);
|
||||
});
|
||||
test('owned scope excludes installed games and keeps unknown compatibility visible',()=>{
|
||||
const f=workspaceFixture(),gm=f.context.gm;
|
||||
Object.assign(gm,{owned:[{id:1,installed:true,name:'Installed',frame:3,sort:'installed'},{id:2,installed:false,name:'Owned unknown',frame:0,sort:'owned',lastPlayed:0}],q:'',filters:{owned:new Set(['0','1','2','3'])},shown:0});
|
||||
f.context.moreGames=()=>{};
|
||||
const start=html.indexOf('function filterGames('),end=html.indexOf('function price(',start);
|
||||
f.run(html.slice(start,end));f.run('filterGames()');
|
||||
assert.deepEqual(Array.from(gm.results,g=>g.id),[2]);
|
||||
});
|
||||
test('installed load succeeds while optional catalog is unresolved, and errors keep local retry',async()=>{
|
||||
const f=workspaceFixture(),pending=deferred();
|
||||
let result={apps:[{package:'org.example.player',label:'Player',running:false}]},fail=false;
|
||||
f.context.api=path=>path==='/api/android'?fail?Promise.reject(Error('Temporary network failure')):Promise.resolve(result):pending.promise;
|
||||
f.context.renderLibrary=()=>f.el('andApps').innerHTML='installed';
|
||||
f.run('let androidSeq=0; const androidPending=new Set(); let installedPackage=null;');
|
||||
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||
f.run(html.slice(start,end));await f.run('loadAndroid()');
|
||||
assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,true);
|
||||
fail=true;await f.run('loadAndroid()');assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,false);
|
||||
assert.match(f.el('androidLoadMessage').textContent,/Temporary network failure/);
|
||||
fail=false;await f.run('loadAndroid()');assert.equal(f.el('androidLoadError').hidden,true);
|
||||
assert.ok(html.includes('loadAndroid();\n\n// ---- Android display'),'startup must not wait for catalog completion');
|
||||
});
|
||||
test('older installed responses cannot replace a newer read',async()=>{
|
||||
const f=workspaceFixture(),old=deferred(),next=deferred();let calls=0;
|
||||
f.context.api=()=>++calls===1?old.promise:next.promise;f.context.renderLibrary=()=>{};
|
||||
f.run('let androidSeq=0;const androidPending=new Set();let installedPackage=null;');
|
||||
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||
f.run(html.slice(start,end));const a=f.run('loadAndroid()'),b=f.run('loadAndroid()');
|
||||
next.resolve({apps:[{package:'new'}]});await b;old.resolve({apps:[{package:'old'}]});await a;
|
||||
assert.equal(f.context.androidApps[0].package,'new');
|
||||
});
|
||||
test('Android Open dispatch is deduplicated while pending and returns to an enabled row',async()=>{
|
||||
const f=workspaceFixture(),request=deferred(),writes=[];
|
||||
Object.assign(f.context,{devGen:1,installing:new Set(),toast(){},act:async(label,work)=>work(),
|
||||
api:(path,body)=>{writes.push({path,body});return request.promise;},loadAndroid:async()=>{},renderLibrary(){},cat:{apps:null},loadReports(){}});
|
||||
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||
const end=html.indexOf('loadAndroid();\n\n// ---- Android display',start);
|
||||
f.run(html.slice(start,end));
|
||||
const button={dataset:{and:'launch',pkg:'org.example.player',name:'Player'},disabled:false,textContent:'Open'};
|
||||
const event={target:{closest:()=>button}};
|
||||
const a=f.listeners['body-click'](event);await f.listeners['body-click'](event);
|
||||
assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');assert.equal(writes.length,1);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/android',body:{action:'launch',package:'org.example.player'}});
|
||||
request.resolve({message:'Opened'});await a;assert.equal(button.disabled,false);assert.equal(button.textContent,'Open');assert.equal(button.ariaBusy,'false');
|
||||
});
|
||||
test('entering Apps Installed refreshes data without clearing cached rows',()=>{
|
||||
const f=workspaceFixture();f.el('andApps').innerHTML='cached installed apps';f.navigate('#games');f.navigate('#apps');
|
||||
assert.equal(f.calls.filter(c=>c[0]==='android').length,1);
|
||||
f.run("selectApps('installed')");assert.equal(f.calls.filter(c=>c[0]==='android').length,2);
|
||||
f.navigate('#settings');f.navigate('#apps');assert.equal(f.calls.filter(c=>c[0]==='android').length,3);
|
||||
assert.equal(f.el('andApps').innerHTML,'cached installed apps');
|
||||
});
|
||||
test('actions from detail synchronize existing rows and stay pending through the follow-up read',async()=>{
|
||||
for(const action of ['launch','stop']) {
|
||||
const f=workspaceFixture(),request=deferred(),refresh=deferred();let writes=0,renders=0;
|
||||
const makeButton=pkg=>({dataset:{and:action,pkg,name:'Player'},disabled:false,textContent:action==='stop'?'Stop':'Open'});
|
||||
const detail=makeButton('org.example.player'),row=makeButton('org.example.player'),other=makeButton('org.other');
|
||||
const originalQuery=f.context.document.querySelectorAll;
|
||||
f.context.document.querySelectorAll=selector=>selector==='[data-and="launch"], [data-and="stop"]'?[row,detail,other]:originalQuery(selector);
|
||||
Object.assign(f.context,{devGen:1,installing:new Set(),log(){},toast(){},api:()=>{writes++;return request.promise;},
|
||||
loadAndroid:()=>refresh.promise,renderLibrary(){renders++;row.disabled=false;row.textContent=action==='stop'?'Stop':'Open';row.ariaBusy='false';},cat:{apps:null},loadReports(){}});
|
||||
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||
f.run(html.slice(html.indexOf('async function act('),html.indexOf('\nfunction setConn(')));
|
||||
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||
f.run(html.slice(start,html.indexOf('loadAndroid();\n\n// ---- Android display',start)));
|
||||
f.el('installedDetail').showModal();const pending=f.listeners['body-click']({target:{closest:()=>detail}});f.el('installedDetail').close();
|
||||
for(const button of [row,detail]){assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');}
|
||||
assert.equal(other.disabled,false);await f.listeners['body-click']({target:{closest:()=>row}});assert.equal(writes,1);
|
||||
request.resolve({message:'Done'});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(row.disabled,true);assert.equal(detail.disabled,true);assert.equal(renders,0,'stay pending while refreshed app state is unresolved');
|
||||
refresh.resolve();await pending;assert.equal(renders,1);assert.equal(row.disabled,false);assert.equal(detail.disabled,false);assert.equal(f.run('androidPending.size'),0);
|
||||
}
|
||||
});
|
||||
test('regenerated Android rows and details keep the compact accessible pending state',()=>{
|
||||
const f=workspaceFixture(),app={package:'org.example.player',label:'Player',running:false};
|
||||
Object.assign(f.context,{androidApps:[app],storeIcon:()=>'<span>icon</span>'});
|
||||
f.run("const androidPending=new Set(['org.example.player']);let installedPackage=null;");
|
||||
const start=html.indexOf('function libCard(a)'),end=html.indexOf('function renderLibrary()',start);f.run(html.slice(start,end));
|
||||
for(const running of [false,true]) {
|
||||
app.running=running;const row=f.run('libCard(androidApps[0])');f.run("openInstalledApp('org.example.player')");
|
||||
assert.match(row,/disabled aria-busy="true">Wait…<\/button>/);
|
||||
assert.match(f.el('installedDetailBody').innerHTML,/disabled aria-busy="true">Wait…<\/button>/);
|
||||
}
|
||||
f.run('androidPending.clear()');const row=f.run('libCard(androidApps[0])');assert.doesNotMatch(row,/aria-busy="true"/);assert.match(row,/>Stop<\/button>/);
|
||||
});
|
||||
test('explicit Capture section links take priority over saved destination scroll',()=>{
|
||||
const f=workspaceFixture();f.navigate('#capture');f.scroll(700);f.calls.length=0;
|
||||
f.navigate('#shots');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='shots'));
|
||||
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||
});
|
||||
test('viewer keyboard shortcuts act only in the Capture workspace',()=>{
|
||||
const f=workspaceFixture(),events=[];let handler;
|
||||
f.context.document.activeElement={tagName:'BODY'};
|
||||
f.context.document.addEventListener=(name,callback)=>handler=callback;
|
||||
Object.assign(f.context,{ctrl:{on:false},zoom:1,setZoom:n=>events.push(['zoom',n])});
|
||||
f.el('ctrlBtn').click=()=>events.push(['control']);f.el('fullBtn').click=()=>events.push(['fullscreen']);
|
||||
const start=html.indexOf('document.addEventListener("keydown", e => {',html.indexOf('$("canvas").addEventListener("pointerdown"'));
|
||||
const end=html.indexOf('\n});',start)+4;f.run(html.slice(start,end));
|
||||
f.navigate('#capture');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||
f.navigate('#home');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||
});
|
||||
test('late Mac streaming responses cannot reveal a different tool panel',async()=>{
|
||||
for(const rejects of [false,true]) {
|
||||
const f=workspaceFixture(),response=deferred();
|
||||
Object.assign(f.context,{api:()=>response.promise,clearTimeout(){},setTimeout(){},failed(){}});
|
||||
f.run('let mvSeq=0,mvTimer=0;');
|
||||
const start=html.indexOf('async function loadMacView(restart)'),end=html.indexOf('$("mvRefresh").onclick',start);
|
||||
f.run(html.slice(start,end));f.context.location.hash='#macview';const pending=f.run('loadMacView()');
|
||||
f.navigate('#transfer');assert.equal(f.el('macview').hidden,true);
|
||||
rejects?response.reject(Error('Network failed')):response.resolve({available:true,streams:[],displays:[],windows:[]});
|
||||
await pending;assert.equal(f.el('macview').hidden,true);assert.equal(f.el('transfer').hidden,false);
|
||||
}
|
||||
});
|
||||
test('Home task count includes the current Steam download',()=>{
|
||||
const f=workspaceFixture();Object.assign(f.context,{state:{hostname:'Frame',battery:{percent:80}},stateRead:{valid:true},online:true});
|
||||
f.context.gm.dl={appid:1,percent:42};f.run('renderHome()');
|
||||
assert.equal(f.el('homeJobs').textContent,'1 task in progress');assert.match(f.el('homeDownload').textContent,/42%/);
|
||||
});
|
||||
test('a late screenshot read cannot replace newer navigation or clear a newer viewer request',async()=>{
|
||||
const f=workspaceFixture(),old=deferred(),newer=deferred();let reads=0;
|
||||
Object.assign(f.context,{shotBlob:()=>++reads===1?old.promise:newer.promise,URL:{createObjectURL:()=> 'blob:shot',revokeObjectURL(){}},
|
||||
Image:class{set src(value){this.onload();}},draw(){},shotApp:()=> 'Game',toast(){},desk:{shown:null}});
|
||||
const start=html.indexOf('async function openShot(s)'),end=html.indexOf('async function saveShots(',start);
|
||||
f.run(html.slice(start,end));f.navigate('#capture');const pending=f.run("openShot({id:1,file:'one.png',time:1})");
|
||||
assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||
f.navigate('#library');f.scroll(240);assert.equal(f.el('viewer').classList.contains('busy'),false);
|
||||
old.resolve({});await pending;assert.equal(f.activePage,'games');assert.equal(f.context.location.hash,'#library');assert.equal(f.context.window.scrollY,240);
|
||||
f.navigate('#capture');const first=f.run("openShot({id:2,file:'two.png',time:1})");
|
||||
f.navigate('#home');f.navigate('#capture');f.run('viewGen++');f.el('viewer').classList.toggle('busy',true);
|
||||
newer.resolve({});await first;assert.equal(f.activePage,'capture');assert.equal(f.context.location.hash,'#capture');assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||
});
|
||||
test('Steam discovery preserves an unsubmitted query across a source change',()=>{
|
||||
const f=workspaceFixture();Object.assign(f.context.gm,{src:'store',q:'owned query',storeDraft:'',storeQ:'submitted query'});
|
||||
f.context.renderChips=()=>{};f.context.filterGames=()=>{};
|
||||
const start=html.indexOf('function setGameSource(src)'),end=html.indexOf('$("gmSrc").onclick',start);
|
||||
f.run(html.slice(start,end));f.el('gmQ').value='unsubmitted search';f.run("setGameSource('owned')");
|
||||
assert.equal(f.el('gmQ').value,'owned query');f.run("setGameSource('store')");assert.equal(f.el('gmQ').value,'unsubmitted search');
|
||||
});
|
||||
test('expanded owned and store results survive source and destination returns',async()=>{
|
||||
const f=workspaceFixture(),gm=f.context.gm;
|
||||
const games=Array.from({length:120},(_,i)=>({id:i+1,name:'Game '+String(i+1).padStart(3,'0'),sort:String(i).padStart(3,'0'),frame:3,installed:false,lastPlayed:0}));
|
||||
Object.assign(gm,{owned:games,store:games,storeQ:'game',storeDraft:'game',country:'US',q:'',results:[],filters:{owned:new Set(['3']),store:new Set(['3'])},seq:0});
|
||||
Object.assign(f.context,{FLABEL:{3:'Verified'},FCLASS:{3:'verified'},CDN:'https://example.invalid',renderChips(){}});
|
||||
f.el('gmGrid').insertAdjacentHTML=(position,s)=>f.el('gmGrid').innerHTML+=s;
|
||||
const start=html.indexOf('function filterGames('),end=html.indexOf('$("gmSrc").onclick',start);
|
||||
f.run(html.slice(start,end));
|
||||
f.navigate('#getgames');f.run('filterGames();moreGames()');f.scroll(7000);
|
||||
assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);
|
||||
f.navigate('#steamStore');f.run('moreGames()');f.scroll(6500);
|
||||
assert.equal(gm.shown,96);f.navigate('#getgames');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,7000);
|
||||
f.navigate('#steamStore');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,6500);
|
||||
f.run('filterGames()');assert.equal(gm.shown,48,'a deliberate filter change resets expansion');
|
||||
const response=deferred();f.context.api=()=>response.promise;f.el('gmQ').value='new search';const search=f.run('searchStore()');
|
||||
f.navigate('#getgames');const ownedMarkup=f.el('gmGrid').innerHTML;
|
||||
response.resolve({results:games.slice(0,60)});await search;
|
||||
assert.equal(f.el('gmGrid').innerHTML,ownedMarkup,'a late store response leaves current owned results intact');
|
||||
f.navigate('#steamStore');assert.equal(gm.shown,48);assert.equal(gm.storeQ,'new search');
|
||||
});
|
||||
test('Home shortcuts show their tool before focusing it, so typing works on the first tap',()=>{
|
||||
const f=workspaceFixture(),pad={state:'on'},buttons=[{dataset:{go:'pad'}}];let focusedVisible=null;
|
||||
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||
Object.assign(f.context,{pad,padStart(){},history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
|
||||
f.navigate('#home');
|
||||
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
|
||||
let shows=0;f.run('const realShowPage=showPage;');f.context.showPage=()=>{shows++;f.run('realShowPage()');};
|
||||
f.run(html.slice(start,end).replace(/showPage\(\)/g,'globalThis.showPage()'));buttons[0].onclick();
|
||||
assert.equal(focusedVisible,true);assert.equal(shows,1);assert.equal(f.context.location.hash,'#pad');
|
||||
});
|
||||
test('a Home shortcut keeps the destination scroll and loads its tool once, with frames deferred',()=>{
|
||||
const f=workspaceFixture(),frames=[],events=[],buttons=[{dataset:{go:'pad'}},{dataset:{go:'macview'}}];
|
||||
// A hash assignment queues hashchange, which a browser may deliver before the next frame.
|
||||
let hash=f.context.location.hash;
|
||||
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
|
||||
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
|
||||
f.context.requestAnimationFrame=fn=>frames.push(fn);
|
||||
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||
Object.assign(f.context,{pad:{state:'on'},padStart(){},history:{pushState(_,__,url){hash=url;}}});
|
||||
f.el('padType').focus=()=>{};
|
||||
f.navigate('#pad');flush();f.scroll(320);
|
||||
f.navigate('#home');flush();f.scroll(150);
|
||||
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
|
||||
f.run(html.slice(start,end));
|
||||
buttons[0].onclick();flush();
|
||||
assert.equal(f.context.window.scrollY,320);
|
||||
f.navigate('#home');flush();f.calls.length=0;
|
||||
buttons[1].onclick();flush();
|
||||
assert.equal(f.calls.filter(c=>c[0]==='macview').length,1);
|
||||
});
|
||||
test('Cast keeps the Capture scroll and shows Capture once, with frames deferred',()=>{
|
||||
const f=workspaceFixture(),frames=[],events=[];let shows=0;
|
||||
let hash=f.context.location.hash;
|
||||
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
|
||||
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
|
||||
f.context.requestAnimationFrame=fn=>frames.push(fn);
|
||||
Object.assign(f.context,{history:{pushState(_,__,url){hash=url;}},setView(){},toggleLive(){}});
|
||||
f.el('viewer').requestFullscreen=()=>Promise.resolve();
|
||||
f.navigate('#capture');flush();f.scroll(320);
|
||||
f.navigate('#home');flush();f.scroll(150);
|
||||
const start=html.indexOf('function showNow('),end=html.indexOf('\n};\n',html.indexOf('$("castBtn").onclick'))+4;
|
||||
f.run(html.slice(start,end).replace(/showPage\(\)/g,'(globalThis.__shows=(globalThis.__shows||0)+1,showPage())'));
|
||||
f.el('castBtn').onclick();flush();
|
||||
assert.equal(f.context.window.scrollY,320);assert.equal(f.run('globalThis.__shows'),1);assert.equal(hash,'#capture');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Execute the production workspace code with only browser/transport boundaries replaced.
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
|
||||
function workspaceFixture() {
|
||||
const elements = new Map(), listeners = {}, calls = [];
|
||||
const classes = () => { const set=new Set(); return {add(k){set.add(k);},toggle(k,on){if(on===undefined)on=!set.has(k);on?set.add(k):set.delete(k);return on;},remove(k){set.delete(k);},contains:k=>set.has(k)}; };
|
||||
function el(id) {
|
||||
if (!elements.has(id)) elements.set(id,{id,hidden:false,disabled:false,open:false,innerHTML:'',textContent:'',value:'',dataset:{},classList:classes(),
|
||||
setAttribute(k,v){this[k]=v;},removeAttribute(k){delete this[k];},querySelectorAll(){return [];},
|
||||
append(node){node.parent=node.parentNode=this;},prepend(node){node.parent=node.parentNode=this;},scrollIntoView(){calls.push(['section',id]);},
|
||||
showModal(){this.open=true;},close(){this.open=false;},addEventListener(k,fn){listeners[id+'-'+k]=fn;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const pages=[...html.matchAll(/data-page="([^"]+)"/g)].map((m,i)=>Object.assign(el('page-'+i),{dataset:{page:m[1]}}));
|
||||
const nav=html.match(/<nav id="nav"[^>]*>([\s\S]*?)<\/nav>/)[1];
|
||||
const links=[...nav.matchAll(/<a href="([^"]+)"/g)].map((m,i)=>Object.assign(el('nav-'+i),{getAttribute:()=>m[1]}));
|
||||
const controls={};
|
||||
for (const [attr,values] of [['scope',['installed','owned','store']],['library-source',['all','steam','sideloaded']],['app-view',['installed','browse','linux']]]) {
|
||||
const prop=attr.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
|
||||
controls[`[data-${attr}]`]=values.map(v=>Object.assign(el(attr+'-'+v),{dataset:{[prop]:v}}));
|
||||
}
|
||||
const win={scrollY:0,scrollTo(a,b){this.scrollY=typeof a==='object'?a.top:b;calls.push(['scroll',this.scrollY]);},addEventListener(k,fn){listeners[k]=fn;}};
|
||||
let hash='#apps';const location={get hash(){return hash;},set hash(value){hash=value&&!String(value).startsWith('#')?'#'+value:String(value);}};
|
||||
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
|
||||
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
|
||||
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
|
||||
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
|
||||
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||
vm.createContext(context);
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
vm.runInContext(html.slice(start,end),context);
|
||||
return {html,context,elements,el,calls,pages,links,listeners,run:code=>vm.runInContext(code,context),
|
||||
navigate(hash){context.location.hash=hash;vm.runInContext('showPage()',context);},
|
||||
get activePage(){return pages.find(p=>p.classList.contains('on'))?.dataset.page;},
|
||||
scroll(y){win.scrollY=y;listeners.scroll();}};
|
||||
}
|
||||
module.exports={workspaceFixture,html};
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
|
||||
|
||||
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
|
||||
them for a headset instead: VR first, then games and the flat apps worth having in a panel
|
||||
(video, reading, making things). Apps built around a phone's hardware or system (calls and
|
||||
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
|
||||
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
|
||||
"""
|
||||
import re
|
||||
|
||||
# (id, label), in the order the store shows them.
|
||||
CATEGORIES = (
|
||||
('vr', 'VR'),
|
||||
('games', 'Games'),
|
||||
('emulators', 'Emulators'),
|
||||
('watch', 'Watch & listen'),
|
||||
('read', 'Read & learn'),
|
||||
('create', 'Create'),
|
||||
('work', 'Work'),
|
||||
('web', 'Web & chat'),
|
||||
('tools', 'Tools'),
|
||||
)
|
||||
IDS = {c for c, _ in CATEGORIES}
|
||||
|
||||
# F-Droid's category names -> a store category; None = only useful on a phone.
|
||||
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
|
||||
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
|
||||
BROAD = {
|
||||
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
|
||||
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
|
||||
'Development': 'tools', 'Security': 'tools',
|
||||
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
|
||||
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
|
||||
'System': None, 'Theming': None,
|
||||
}
|
||||
FINE = {
|
||||
'emulators': ('Emulator',),
|
||||
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
|
||||
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
|
||||
'Strategy Game', 'Visual Novel', 'Word Game'),
|
||||
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
|
||||
'Ambient Sound'),
|
||||
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
|
||||
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
|
||||
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
|
||||
'Bookmark'),
|
||||
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
|
||||
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
|
||||
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
|
||||
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
|
||||
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
|
||||
}
|
||||
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
|
||||
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
|
||||
PHONE_SYSTEM = {
|
||||
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
|
||||
'Theming', 'Emergency Action', 'Xposed', 'Automation',
|
||||
}
|
||||
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
|
||||
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
|
||||
# category says otherwise. A finer one when the source has them; a broad one when it only has
|
||||
# broad ones (IzzyOnDroid often does).
|
||||
PHONE_SIDE = {
|
||||
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
|
||||
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
|
||||
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
|
||||
'Connectivity',
|
||||
}
|
||||
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
|
||||
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
|
||||
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
|
||||
'Meditation', 'Mental Health', 'Sports & Health'}
|
||||
# When an app has several, the one that best describes it on a headset wins.
|
||||
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
|
||||
|
||||
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
|
||||
# What an app is, from its name: these are phone parts, whatever the categories say.
|
||||
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
|
||||
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
|
||||
r'magisk|xposed|battery)\b|\[root\]', re.I)
|
||||
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
|
||||
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
|
||||
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
|
||||
# Needs hardware or a device the Frame doesn't have, from the name or summary.
|
||||
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
|
||||
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
|
||||
|
||||
|
||||
def classify(entry):
|
||||
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
|
||||
if entry.get('vr') is True:
|
||||
return {'category': 'vr', 'phone_only': False}
|
||||
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
|
||||
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
|
||||
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
|
||||
broad = {BROAD[c] for c in names if BROAD.get(c)}
|
||||
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
|
||||
if category is None and EMULATOR.search(name + ' ' + summary):
|
||||
category = 'emulators'
|
||||
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
|
||||
useful = fine or (broad if only_broad else set())
|
||||
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
|
||||
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
|
||||
for c in names):
|
||||
phone = True
|
||||
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
|
||||
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
|
||||
return {'category': category, 'phone_only': phone}
|
||||
|
||||
|
||||
def matches(entry, category=None, phone=False):
|
||||
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
|
||||
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
|
||||
if entry.get('phone_only') and not phone:
|
||||
return False
|
||||
return not category or entry.get('category') == category
|
||||
+38
-11
@@ -12,7 +12,9 @@ from urllib.parse import urljoin, urlsplit
|
||||
from apk_sources import SourceError, _credentials
|
||||
|
||||
_lock = threading.Lock()
|
||||
_urls = OrderedDict()
|
||||
_urls = OrderedDict() # token -> URL, oldest first
|
||||
_tokens = {} # URL -> token
|
||||
MAX_TOKENS = 16384
|
||||
_cache = OrderedDict()
|
||||
MAX_IMAGE = 8 * 1024 * 1024
|
||||
MAX_CACHE = 64 * 1024 * 1024
|
||||
@@ -31,24 +33,24 @@ def register(url):
|
||||
if not isinstance(url, str) or not valid_url(url):
|
||||
return None
|
||||
with _lock:
|
||||
for token, known in _urls.items():
|
||||
if known == url:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = _tokens.get(url)
|
||||
if token in _urls:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = secrets.token_urlsafe(24)
|
||||
_urls[token] = url
|
||||
while len(_urls) > 4096:
|
||||
_urls.popitem(last=False)
|
||||
_urls[token], _tokens[url] = url, token
|
||||
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
|
||||
_tokens.pop(_urls.popitem(last=False)[1], None)
|
||||
return '/source-image/' + token
|
||||
|
||||
|
||||
def artwork(entry):
|
||||
def artwork(entry, shots=12):
|
||||
images = entry.get('images') or {}
|
||||
if not isinstance(images, dict):
|
||||
images = {}
|
||||
return {'icon': register(images.get('icon') or entry.get('icon')),
|
||||
'banner': register(images.get('banner')),
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
|
||||
|
||||
|
||||
def image_type(data):
|
||||
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
|
||||
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
||||
_resolvers = threading.BoundedSemaphore(4)
|
||||
# A page of store cards asks for dozens of images from the same few hosts at once: their public
|
||||
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
|
||||
_dns = {} # (host, port) -> (addresses, expires)
|
||||
DNS_TTL = 300
|
||||
|
||||
|
||||
def _resolve(host, port, timeout):
|
||||
with _lock:
|
||||
known = _dns.get((host, port))
|
||||
if known and known[1] > time.monotonic():
|
||||
return known[0]
|
||||
found = _lookup(host, port, timeout)
|
||||
try:
|
||||
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
public = False
|
||||
if public: # private answers are refused by the caller, and never kept
|
||||
with _lock:
|
||||
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
|
||||
while len(_dns) > 256:
|
||||
_dns.pop(next(iter(_dns)))
|
||||
return found
|
||||
|
||||
|
||||
def _lookup(host, port, timeout):
|
||||
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||
if not _resolvers.acquire(blocking=False):
|
||||
# A request waits for a free slot within its own budget rather than failing at once.
|
||||
started = time.monotonic()
|
||||
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
|
||||
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
||||
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
|
||||
found = {}
|
||||
|
||||
def run():
|
||||
|
||||
@@ -26,7 +26,7 @@ from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
CACHE_VERSION = 3 # 3: categories
|
||||
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
@@ -474,6 +474,7 @@ def _reduce(path, source):
|
||||
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
|
||||
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
|
||||
page=meta.get('webSite') or source['url'], vr=None, free=True,
|
||||
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
|
||||
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
|
||||
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
|
||||
return result
|
||||
@@ -492,6 +493,7 @@ def _v1(content, path):
|
||||
localized = app.get('localized', {})
|
||||
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
|
||||
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
|
||||
meta['categories'] = app.get('categories') or []
|
||||
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
|
||||
images = {}
|
||||
for locale, fields in localized.items():
|
||||
@@ -684,16 +686,39 @@ def set_enabled(source_id, enabled):
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
def _showcase(a):
|
||||
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
|
||||
images = a.get('images') or {}
|
||||
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
|
||||
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
|
||||
|
||||
|
||||
def search(source, query, limit=50, keep=None):
|
||||
"""keep: optional filter applied before the limit (the store's category browse)."""
|
||||
if not source.get('enabled', True):
|
||||
return []
|
||||
apps, _ = _load(source)
|
||||
words = query.casefold().split()
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
|
||||
and (keep is None or keep(a))]
|
||||
if words:
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
else:
|
||||
found.sort(key=_showcase)
|
||||
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
|
||||
|
||||
|
||||
def lookup(source, packages):
|
||||
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
|
||||
if not source.get('enabled', True):
|
||||
return {}
|
||||
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
|
||||
if not found:
|
||||
return {}
|
||||
apps = found[0]
|
||||
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if not source.get('enabled', True):
|
||||
raise SourceError('repository is disabled')
|
||||
|
||||
+58
-14
@@ -15,7 +15,7 @@ import time
|
||||
import unicodedata
|
||||
|
||||
import frame_host
|
||||
from apk_sources import SourceError, SourceLimited
|
||||
from apk_sources import SourceError, SourceLimited, _categories as categories
|
||||
|
||||
_lock = threading.RLock()
|
||||
_running = {}
|
||||
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
|
||||
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
|
||||
|
||||
|
||||
def modules():
|
||||
@@ -166,9 +167,11 @@ def verdict(entry):
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry):
|
||||
def decorate(entry, shots=12):
|
||||
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
|
||||
**categories.classify(entry))
|
||||
|
||||
|
||||
def details(source_id, entry_id):
|
||||
@@ -186,10 +189,12 @@ def offer_rank(entry):
|
||||
entry.get('trust') == 'official')
|
||||
|
||||
|
||||
def group(entries, query='', vr=None, installable=False):
|
||||
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
|
||||
groups = {}
|
||||
for entry in entries:
|
||||
entry = decorate(entry)
|
||||
entry = decorate(entry, shots=3)
|
||||
if not categories.matches(entry, category, phone):
|
||||
continue
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
continue
|
||||
if installable and entry['fit']['installable'] is not True:
|
||||
@@ -203,7 +208,7 @@ def group(entries, query='', vr=None, installable=False):
|
||||
offers.sort(key=offer_rank, reverse=True)
|
||||
best = offers[0]
|
||||
result.append({'name': best.get('name'), 'package': best.get('package'),
|
||||
'summary': best.get('summary'), 'offers': offers})
|
||||
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
|
||||
q = normalise(query)
|
||||
|
||||
def browse(a): # a headset store: VR first, then apps with artwork, newest first
|
||||
@@ -223,10 +228,10 @@ def group(entries, query='', vr=None, installable=False):
|
||||
return result
|
||||
|
||||
|
||||
def _launch(module, source, query, limit):
|
||||
def _launch(module, source, query, limit, category=None, phone=False):
|
||||
"""One search per source at a time; the newest different query runs next."""
|
||||
key = source['id']
|
||||
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
|
||||
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
|
||||
'job': (module, source)}
|
||||
with _lock:
|
||||
old = _running.get(key)
|
||||
@@ -245,13 +250,17 @@ def _launch(module, source, query, limit):
|
||||
|
||||
def _start(key, task):
|
||||
module, source = task['job']
|
||||
query, limit = task['query']
|
||||
query, limit, category, phone = task['query']
|
||||
kwargs = {}
|
||||
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
|
||||
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
|
||||
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
|
||||
|
||||
def run():
|
||||
queued = None
|
||||
try:
|
||||
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
|
||||
for e in module.search(source, query, limit=limit) if e.get('free') is True]
|
||||
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
|
||||
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
|
||||
except Exception as e:
|
||||
task['error'] = str(e)
|
||||
@@ -267,14 +276,20 @@ def _start(key, task):
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
|
||||
items, errors = registry()
|
||||
if source and source not in [s['id'] for _, s in items]:
|
||||
raise SourceError('Unknown source')
|
||||
if category and category not in categories.IDS:
|
||||
raise SourceError('Unknown category')
|
||||
if not query.strip():
|
||||
limit = max(limit, BROWSE_LIMIT)
|
||||
else:
|
||||
phone = True # the phone-only guess hides apps from browsing, never from a search for them
|
||||
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
|
||||
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
|
||||
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
|
||||
entries, statuses = [], list(errors)
|
||||
for s, task in tasks:
|
||||
status = {'id': s['id'], 'name': s['name']}
|
||||
@@ -289,7 +304,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
|
||||
statuses.append(status)
|
||||
with _lock:
|
||||
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
|
||||
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
|
||||
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
|
||||
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
|
||||
|
||||
|
||||
def warm():
|
||||
@@ -299,7 +315,35 @@ def warm():
|
||||
items, _ = registry()
|
||||
for m, s in items:
|
||||
if s['enabled'] and not s.get('page_only'):
|
||||
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
|
||||
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
|
||||
|
||||
|
||||
def artwork_for(apps):
|
||||
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
|
||||
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
|
||||
from apk_sources import _images
|
||||
items, _ = registry()
|
||||
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
|
||||
found = {}
|
||||
for module, source in items:
|
||||
if not source['enabled'] or source.get('page_only'):
|
||||
continue
|
||||
try:
|
||||
if hasattr(module, 'lookup'):
|
||||
hits = module.lookup(source, [p for p in wanted if p not in found])
|
||||
elif module.KIND == 'github': # a small local list; its entries have names, not packages
|
||||
names = {n: p for p, n in wanted.items() if n and p not in found}
|
||||
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
|
||||
if normalise(e.get('name')) in names}
|
||||
else:
|
||||
continue
|
||||
except (SourceError, OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
for package, entry in hits.items():
|
||||
art = _images.artwork(entry)
|
||||
if art['icon'] or art['banner'] or art['screenshots']:
|
||||
found.setdefault(package, art)
|
||||
return found
|
||||
|
||||
|
||||
def install(source_id, entry_id, version_code=None, progress=None):
|
||||
|
||||
+3
-1
@@ -14,7 +14,7 @@ button.primary { background:#176b9c } button:disabled { opacity:.5; cursor:wait
|
||||
summary { overflow-wrap:anywhere; cursor:pointer }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380px; overflow:auto } [hidden] { display:none!important } #status { min-height:1.5em } small { color:#b8c6d5 }
|
||||
</style>
|
||||
<header><h1>Frame Control · Assistant</h1><a href="/">Back to Frame Control</a></header>
|
||||
<header><h1>Frame Control · Assistant</h1><a id="back" href="/" referrerpolicy="no-referrer">Back to Frame Control</a></header>
|
||||
<section id="approval" hidden aria-labelledby="approval-title">
|
||||
<h2 id="approval-title">An agent wants to change your Frame</h2>
|
||||
<p>Review the exact action below. Approve only if you asked for it. Approval expires after five minutes and works once.</p>
|
||||
@@ -42,6 +42,8 @@ pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const key = __FRAME_KEY__;
|
||||
const backParams = new URLSearchParams({key});
|
||||
$('back').href = '/?' + backParams;
|
||||
let generation = 0;
|
||||
async function api(path, body) {
|
||||
const response = await fetch(path, {method:body === undefined ? 'GET' : 'POST',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
|
||||
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
|
||||
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
|
||||
@@ -77,7 +77,7 @@
|
||||
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
|
||||
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
|
||||
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
|
||||
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
|
||||
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#linuxApps','Manage Linux apps']]},
|
||||
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
|
||||
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
|
||||
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
|
||||
|
||||
@@ -90,6 +90,13 @@ def validate(name, args):
|
||||
|
||||
|
||||
def call(server, body):
|
||||
# Keep target validation, token consumption and dispatch inside one work
|
||||
# reservation, including direct callers outside the HTTP handler.
|
||||
with server.working():
|
||||
return _call(server, body)
|
||||
|
||||
|
||||
def _call(server, body):
|
||||
name, args = body.get('name'), body.get('arguments', {})
|
||||
action = validate(name, args)
|
||||
if name in ('install', 'uninstall', 'panel') and not server.FLATPAK_ID.fullmatch(args['id']):
|
||||
@@ -98,6 +105,7 @@ def call(server, body):
|
||||
raise ValueError('Expected a Steam app ID')
|
||||
if name == 'keep_awake' and args['action'] == 'status':
|
||||
return keep_awake(server, 'status')
|
||||
action['target'] = server.agent_target()
|
||||
token = body.get('confirmation')
|
||||
if not token:
|
||||
return approvals.request(action)
|
||||
|
||||
+150
-10
@@ -187,6 +187,58 @@ def _write_meta(d, meta):
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
# Only files this installer owns. Lepton compatdata and any other files beside
|
||||
# the app (including user data) are never part of update rollback.
|
||||
UPDATE_FILES = ('app.apk', 'launch.sh', 'instance.id', 'lepton-show-flatscreen',
|
||||
'shortcut.id', 'meta.json', 'artwork')
|
||||
|
||||
|
||||
class UpdateRecoveryPending(FrameError):
|
||||
"""A new attempt does not own, and must not rewrite, an earlier recovery."""
|
||||
|
||||
|
||||
def _update_guard(d):
|
||||
return (f'if [ -e {d}/update-recovery.json ] || [ -e {d}/.frame-update-backup ]; '
|
||||
'then echo FRAME_UPDATE_RECOVERY_PENDING; exit 0; fi; ')
|
||||
|
||||
|
||||
def _check_update_guard(reply, d):
|
||||
if reply.strip() == 'FRAME_UPDATE_RECOVERY_PENDING':
|
||||
pkg = d.rsplit('/', 1)[-1]
|
||||
raise UpdateRecoveryPending(f'Pending APK update recovery at {d}; prior record and backup preserved. '
|
||||
f'Repair with `python3 ui/frame_android.py refresh-art {pkg}` before retrying')
|
||||
|
||||
|
||||
def _backup_update(d):
|
||||
backup = f'{d}/.frame-update-backup'
|
||||
files = ' '.join(UPDATE_FILES[1:])
|
||||
# The APK is only ever replaced by rename, so a hard link keeps its old bytes
|
||||
# without a second multi-gigabyte copy. Smaller mutable files need copies.
|
||||
reply = ssh(_update_guard(d) + f'set -eu; mkdir {backup}; ln {d}/app.apk {backup}/app.apk; '
|
||||
f'for f in {files}; do if [ -e {d}/"$f" ]; then cp -pR {d}/"$f" {backup}/"$f"; fi; done; '
|
||||
f'touch {backup}/ready', timeout=120)
|
||||
_check_update_guard(reply, d)
|
||||
|
||||
|
||||
def _restore_update(d, keep_backup=False):
|
||||
backup = f'{d}/.frame-update-backup'
|
||||
# Retain the snapshot if any restore step fails, so refresh-art can retry.
|
||||
ssh(f'set -eu; test -f {backup}/ready; '
|
||||
f'for f in {" ".join(UPDATE_FILES)}; do '
|
||||
f'if [ -e {backup}/"$f" ]; then '
|
||||
f'rm -rf {d}/"$f"; cp -pR {backup}/"$f" {d}/"$f"; '
|
||||
f'else rm -rf {d}/"$f"; fi; done; '
|
||||
f'rm -f {d}/app.apk.part {d}/meta.json.tmp; '
|
||||
+ ('' if keep_backup else f'rm -rf {backup}'), timeout=120)
|
||||
|
||||
|
||||
def _finish_update_recovery(d):
|
||||
# The durable repair_completed checkpoint is written before this command.
|
||||
# A partial cleanup or lost reply can then be retried without another restore.
|
||||
ssh(f'rm -rf {d}/.frame-update-backup && '
|
||||
f'rm -f {d}/update-recovery.json {d}/update-recovery.json.tmp', timeout=30)
|
||||
|
||||
|
||||
# Called after every install, worked or not, as fn(info, meta, error, seconds):
|
||||
# info is None if the APK couldn't be read, meta None and error set if it failed.
|
||||
install_hooks = []
|
||||
@@ -541,16 +593,22 @@ def _after_install(info, meta, error, start):
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Outside this attempt's transaction/error handler: a refusal cannot replace
|
||||
# an earlier orphan ID, rollback uncertainty, snapshot or recovery record.
|
||||
_check_update_guard(ssh(_update_guard(d)), d)
|
||||
try:
|
||||
images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
|
||||
except (ValueError, OSError) as e:
|
||||
raise FrameError(f'could not prepare artwork: {e}') from e
|
||||
iid = instance_id(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
existing = read_meta(pkg)
|
||||
ok, created = False, None
|
||||
ok, created, backed_up, library_started = False, None, False, False
|
||||
try:
|
||||
ssh(f'mkdir -p {d}')
|
||||
if existing:
|
||||
_backup_update(d)
|
||||
backed_up = True
|
||||
_copy(apk_path, f'{d}/app.apk.part')
|
||||
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
|
||||
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
|
||||
@@ -564,6 +622,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
if not shortcut:
|
||||
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
|
||||
created = shortcut
|
||||
library_started = True # configure can mutate Steam before raising
|
||||
presentation = apply_library(shortcut, name or info['label'], d, images,
|
||||
vr=not flatscreen, home=home,
|
||||
exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
|
||||
@@ -580,26 +639,72 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
'library_warnings': presentation.get('warnings', []),
|
||||
'artwork': presentation.get('artwork', {}), 'library_version': 2}
|
||||
_write_meta(d, meta)
|
||||
if backed_up:
|
||||
ssh(f'rm -rf {d}/.frame-update-backup', timeout=30)
|
||||
ok = True
|
||||
return meta
|
||||
except UpdateRecoveryPending:
|
||||
raise # a recovery appeared after preflight; this attempt still owns nothing
|
||||
except Exception as error:
|
||||
if existing:
|
||||
orphan, cleanup_error = None, None
|
||||
if created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except Exception as e:
|
||||
orphan, cleanup_error = created, str(e) or type(e).__name__
|
||||
created = None # any failed cleanup is recorded and retried by repair
|
||||
rollback_error = None
|
||||
if backed_up:
|
||||
try:
|
||||
_restore_update(d)
|
||||
except Exception as e:
|
||||
rollback_error = str(e) or type(e).__name__
|
||||
if library_started or rollback_error or cleanup_error or not backed_up:
|
||||
repair = f'python3 ui/frame_android.py refresh-art {pkg}'
|
||||
record = {'error': str(error) or type(error).__name__,
|
||||
'files_restored': backed_up and rollback_error is None,
|
||||
'files_unchanged': not backed_up,
|
||||
'library_may_have_changed': library_started,
|
||||
'rollback_error': rollback_error, 'repair': repair,
|
||||
'orphan_shortcut': orphan, 'cleanup_error': cleanup_error,
|
||||
'backup': f'{d}/.frame-update-backup'}
|
||||
evidence_error = None
|
||||
try:
|
||||
ssh(f'cat > {d}/update-recovery.json.tmp && '
|
||||
f'mv {d}/update-recovery.json.tmp {d}/update-recovery.json', input=json.dumps(record))
|
||||
except Exception as e:
|
||||
evidence_error = str(e) or type(e).__name__
|
||||
state = 'Previous app files restored' if record['files_restored'] else 'App file rollback unconfirmed'
|
||||
message = (f'{error}. {state}; Steam library may need repair. '
|
||||
f'Repair with `{repair}`. Recovery: {d}/update-recovery.json; '
|
||||
f'backup: {record["backup"]}')
|
||||
if rollback_error:
|
||||
message += f'; rollback failed: {rollback_error}'
|
||||
if cleanup_error:
|
||||
message += f'; shortcut {orphan} cleanup failed: {cleanup_error}'
|
||||
if evidence_error:
|
||||
message += f'; could not save recovery record: {evidence_error}'
|
||||
raise FrameError(message) from error
|
||||
raise
|
||||
finally:
|
||||
if not ok and created:
|
||||
try:
|
||||
shortcut_tool('remove', str(created))
|
||||
except FrameError:
|
||||
except Exception:
|
||||
pass
|
||||
if not ok and not existing:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
try:
|
||||
ssh(f'rm -rf {d}', timeout=30)
|
||||
except FrameError:
|
||||
except Exception:
|
||||
pass
|
||||
elif not ok:
|
||||
# An update that failed: the app still works, but a partial copy of the new APK
|
||||
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
|
||||
elif not ok and backed_up:
|
||||
# No executable files or backups are deleted here. Rollback above
|
||||
# reports its own failure and retains the snapshot for repair.
|
||||
try:
|
||||
ssh(f'rm -f {d}/app.apk.part', timeout=30)
|
||||
except FrameError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -703,9 +808,36 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
except Exception as e: # one app's failure must not stop the others
|
||||
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
|
||||
return results
|
||||
if not PKG_RE.fullmatch(pkg or ''):
|
||||
raise FrameError(f'bad package name {pkg!r}')
|
||||
with _install_lock: # shared with install and remove: a removed app is never recreated
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
recovering = ssh(f'test -f {d}/update-recovery.json && echo yes || true').strip() == 'yes'
|
||||
if not recovering and ssh(f'test -e {d}/.frame-update-backup && echo yes || true').strip() == 'yes':
|
||||
raise FrameError(f'Update snapshot at {d}/.frame-update-backup has no recovery record; '
|
||||
'rollback is unconfirmed. Inspect and preserve the snapshot before repair')
|
||||
recovery = json.loads(ssh(f'cat {d}/update-recovery.json')) if recovering else {}
|
||||
if recovery.get('repair_completed') is True:
|
||||
m = _meta_or_fail(pkg)
|
||||
_finish_update_recovery(d)
|
||||
return m
|
||||
if recovering:
|
||||
snapshot = ssh(f'if [ -f {d}/.frame-update-backup/ready ]; then echo ready; '
|
||||
f'elif [ -d {d}/.frame-update-backup ]; then echo incomplete; fi').strip()
|
||||
if snapshot == 'ready':
|
||||
_restore_update(d, keep_backup=True)
|
||||
elif snapshot == 'incomplete':
|
||||
raise FrameError(f'Incomplete update snapshot at {d}/.frame-update-backup; '
|
||||
'original app files were not changed. Inspect and remove this '
|
||||
'incomplete snapshot before retrying refresh-art')
|
||||
elif not (recovery.get('files_restored') or recovery.get('files_unchanged')):
|
||||
raise FrameError(f'App rollback is unconfirmed and {d}/.frame-update-backup is absent; '
|
||||
'inspect the APK, launcher, display marker and metadata before repair. '
|
||||
f'Keep {d}/update-recovery.json as evidence')
|
||||
m = _meta_or_fail(pkg)
|
||||
orphan = _int(recovery.get('orphan_shortcut'))
|
||||
if orphan and orphan != m['shortcut']:
|
||||
shortcut_tool('remove', str(orphan))
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
script = _apk_parser_script()
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
@@ -726,7 +858,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
try:
|
||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
|
||||
fill_only=fill_only and not created)
|
||||
fill_only=fill_only and not created and not recovering)
|
||||
except Exception:
|
||||
if created:
|
||||
try:
|
||||
@@ -738,6 +870,14 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
_write_meta(d, m)
|
||||
if recovering:
|
||||
# Keep the original failure/orphan evidence and snapshot until the
|
||||
# whole repair is committed. Persist completion before cleanup so
|
||||
# any failed/lost cleanup response leaves a safe retry path.
|
||||
recovery['repair_completed'] = True
|
||||
ssh(f'cat > {d}/update-recovery.json.tmp && '
|
||||
f'mv {d}/update-recovery.json.tmp {d}/update-recovery.json', input=json.dumps(recovery))
|
||||
_finish_update_recovery(d)
|
||||
return m
|
||||
|
||||
def _int(v):
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Import a local Android backup: one APK and its matching expansion files."""
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frame_android as android
|
||||
import frame_quest as quest
|
||||
|
||||
OBB = re.compile(r'(main|patch)\.[0-9]+\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\.obb')
|
||||
|
||||
|
||||
def inspect(paths, discover=False):
|
||||
"""Inspect selected files before installing anything. Desktop APK drops can find
|
||||
sibling OBBs; browser uploads include only files explicitly selected by the user."""
|
||||
if not isinstance(paths, list) or not paths or len(paths) > 100:
|
||||
raise android.FrameError('Choose one game APK and its .obb game-data files together.')
|
||||
files = []
|
||||
for value in paths:
|
||||
path = Path(value).expanduser().absolute()
|
||||
if path.is_dir():
|
||||
files.extend(p for p in sorted(path.iterdir()) if p.suffix.lower() in ('.apk', '.obb'))
|
||||
else:
|
||||
files.append(path)
|
||||
files = list(dict.fromkeys(files))
|
||||
if not files or len(files) > 100:
|
||||
raise android.FrameError('No Android backup found. Choose a folder containing one APK and its game data.')
|
||||
for path in files:
|
||||
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
|
||||
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
|
||||
apks = [p for p in files if p.suffix.lower() == '.apk']
|
||||
if len(apks) > 1:
|
||||
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
|
||||
apk = apks[0] if apks else None
|
||||
if apk:
|
||||
info = android.apk_info(str(apk))
|
||||
try:
|
||||
android.check_installable(info)
|
||||
except android.FrameError as error:
|
||||
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
|
||||
'blocker': str(error)}
|
||||
raise
|
||||
checked = quest.inspect(info['package'], str(apk))
|
||||
pkg = checked['package']
|
||||
if discover and len(files) == 1:
|
||||
files.extend(p for p in sorted(apk.parent.iterdir())
|
||||
if p not in files and OBB.fullmatch(p.name) and OBB.fullmatch(p.name)[2] == pkg)
|
||||
else:
|
||||
packages = {OBB.fullmatch(p.name)[2] for p in files if OBB.fullmatch(p.name)}
|
||||
if len(packages) != 1:
|
||||
raise android.FrameError('Choose the APK and game-data files for one game together.')
|
||||
pkg = packages.pop()
|
||||
if not android.PKG_RE.fullmatch(pkg):
|
||||
raise android.FrameError('The game-data filename has an invalid Android package name.')
|
||||
meta = android.read_meta(pkg)
|
||||
if not meta:
|
||||
raise android.FrameError('This game is not installed. Add its APK alongside these game-data files.')
|
||||
checked = {k: meta.get(k) for k in ('package', 'label', 'version', 'version_code')}
|
||||
checked['version_code'] = quest._installed_code(pkg, meta)
|
||||
if checked['version_code'] is None:
|
||||
raise android.FrameError('Add the matching APK so Frame Control can check which game version this data belongs to.')
|
||||
checked['warnings'] = []
|
||||
obb = [p for p in files if p.suffix.lower() == '.obb']
|
||||
if len(obb) > 99:
|
||||
raise android.FrameError('Choose one APK and at most 99 game-data files.')
|
||||
if obb and checked['version_code'] is None:
|
||||
raise android.FrameError('The APK has no readable version code, so its game-data target cannot be checked.')
|
||||
names = set()
|
||||
for path in obb:
|
||||
match = OBB.fullmatch(path.name)
|
||||
if not match or match[2] != pkg:
|
||||
raise android.FrameError(f'{path.name} does not belong to {checked["label"]}. Choose matching game data.')
|
||||
if not path.is_file() or not path.stat().st_size or path.name in names:
|
||||
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
|
||||
names.add(path.name)
|
||||
return {'apk': apk, 'obb': obb, 'checked': checked,
|
||||
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
|
||||
|
||||
|
||||
def public(plan):
|
||||
checked = plan['checked']
|
||||
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
|
||||
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
|
||||
'size': sum(size for size, _ in plan['signatures'].values())}
|
||||
|
||||
|
||||
def install(plan, display='auto', progress=None):
|
||||
"""The explicit Import button authorizes starting the game to add its data.
|
||||
Reuse the Quest import's instance/version checks, verified transfer and close."""
|
||||
report = progress or (lambda *_: None)
|
||||
if display not in ('auto', 'flat', 'vr'):
|
||||
raise android.FrameError('Invalid Android display mode.')
|
||||
for path, signature in plan['signatures'].items():
|
||||
p = Path(path)
|
||||
if not p.is_file() or (p.stat().st_size, p.stat().st_mtime_ns) != signature:
|
||||
raise android.FrameError('The backup changed after it was read. Choose the files again.')
|
||||
checked = plan['checked']
|
||||
pkg, label = checked['package'], checked['label']
|
||||
with android._install_lock:
|
||||
if plan['apk']:
|
||||
if quest._running(pkg):
|
||||
raise android.FrameError(f'Close {label} on the Frame, then try importing again.')
|
||||
quest._check_downgrade(android.read_meta(pkg), checked, False)
|
||||
report('Installing the game app', 15)
|
||||
meta = android.install(str(plan['apk']), source=plan['apk'].name,
|
||||
flatscreen=None if display == 'auto' else display == 'flat')
|
||||
else:
|
||||
meta = android._meta_or_fail(pkg)
|
||||
if not plan['obb']:
|
||||
report('Done', 100)
|
||||
warnings = list(checked['warnings']) + list(meta.get('vr_issues') or [])
|
||||
return {'message': f'Installed {label}. Open it from your Steam library.', 'app': meta,
|
||||
'needs_game_data': False, 'warnings': warnings}
|
||||
try:
|
||||
if not quest._running(pkg):
|
||||
report('Starting the game to add its data', 55)
|
||||
android.launch(pkg)
|
||||
deadline = time.monotonic() + 90
|
||||
while not quest._running(pkg):
|
||||
if time.monotonic() >= deadline:
|
||||
raise android.FrameError('The game did not start. Check that Steam is open on the Frame.')
|
||||
time.sleep(2)
|
||||
report('Preparing Android for game data', 65)
|
||||
quest._wait_ready(meta)
|
||||
# Android can be booted before Lepton has installed the APK.
|
||||
deadline = time.monotonic() + 90
|
||||
while True:
|
||||
try:
|
||||
quest._check_android_package(meta, checked['version_code'])
|
||||
break
|
||||
except quest.QuestError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(2)
|
||||
result = quest.add_game_data(pkg, checked, plan['obb'], None, report,
|
||||
checked['warnings'], source='your backup')
|
||||
if result['closed']:
|
||||
result['message'] = (f'Imported {label} and {len(plan["obb"])} game-data '
|
||||
f'file{"s" if len(plan["obb"]) != 1 else ""}. '
|
||||
'Ready to open from your Steam library.')
|
||||
return result
|
||||
except Exception as error:
|
||||
closed, why = quest._close(pkg)
|
||||
detail = ('The game is closed.' if closed else f'Close the game on the Frame ({why}).')
|
||||
raise android.FrameError(f'{label} is installed, but its game-data setup did not finish: {error}. '
|
||||
f'{detail} Import the same backup again to retry.') from error
|
||||
+10
-4
@@ -558,15 +558,21 @@ class Registry:
|
||||
self.config = Path(config) if config else None # None: ssh_config() at call time
|
||||
self.lock = threading.RLock()
|
||||
self._depth = 0 # nested _changing() calls
|
||||
self._mtime = None # devices.json as last loaded or saved
|
||||
self._stamp = None # devices.json as last loaded or saved
|
||||
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
|
||||
self.load()
|
||||
|
||||
# -- storage --
|
||||
def _file_stamp(self):
|
||||
stat = self.path.stat()
|
||||
# Saves atomically replace the file. Fast consecutive saves can have the
|
||||
# same timestamp and size; its identity still changes in that case.
|
||||
return stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size
|
||||
|
||||
def load(self):
|
||||
with self.lock:
|
||||
try:
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
self._stamp = self._file_stamp()
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
@@ -607,14 +613,14 @@ class Registry:
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, self.path)
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
self._stamp = self._file_stamp()
|
||||
|
||||
def _refresh(self):
|
||||
"""Pick up what another Frame Control server saved (the app and a standalone
|
||||
server can share devices.json)."""
|
||||
with self.lock:
|
||||
try:
|
||||
if self.path.stat().st_mtime_ns != self._mtime:
|
||||
if self._file_stamp() != self._stamp:
|
||||
self.load()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
+17
-29
@@ -56,12 +56,12 @@ BROWSER_FLAGS = []
|
||||
PANEL_BOX = (1920, 1080)
|
||||
|
||||
# Opens one viewer on gamescope's X display and gives its window its own panel
|
||||
# id. Args: appid url width height tag profile [browser flags...]. The page puts "[tag]" in its title at
|
||||
# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
|
||||
# once, which is how its X window is found (Chromium may hand the URL to an
|
||||
# instance that's already running, so there's no process to follow).
|
||||
LAUNCH = r"""set -u
|
||||
appid=$1 url=$2 w=$3 h=$4 tag=$5 profile=$6
|
||||
shift 6
|
||||
appid=$1 url=$2 w=$3 h=$4 tag=$5
|
||||
shift 5
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
|
||||
@@ -73,15 +73,15 @@ common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-d
|
||||
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
|
||||
"--window-size=$w,$h" "$@" "--app=$url")
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/$profile" "${common[@]}")
|
||||
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
cmd=(flatpak run org.chromium.Chromium
|
||||
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-$profile" "${common[@]}")
|
||||
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
|
||||
else
|
||||
echo "NO_BROWSER"
|
||||
exit 3
|
||||
fi
|
||||
log=/tmp/frame-$profile.log
|
||||
log=/tmp/frame-mac-view.log
|
||||
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
@@ -105,9 +105,9 @@ class MacViewError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def panel_id(src, host="mac"):
|
||||
def panel_id(src):
|
||||
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
|
||||
return 2_001_000_000 + zlib.crc32(f"{host}:{src}".encode()) % 1_000_000
|
||||
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
|
||||
|
||||
|
||||
def fit(w, h, box=PANEL_BOX):
|
||||
@@ -116,9 +116,6 @@ def fit(w, h, box=PANEL_BOX):
|
||||
|
||||
|
||||
class MacView:
|
||||
host = "mac"
|
||||
viewer_profile = "mac-view"
|
||||
|
||||
def __init__(self, tunnel_ssh, run, frame, track=None):
|
||||
self.tunnel_ssh = list(tunnel_ssh)
|
||||
self.run = run
|
||||
@@ -173,17 +170,10 @@ class MacView:
|
||||
def _stale(self):
|
||||
try:
|
||||
built = AGENT.stat().st_mtime
|
||||
return any(p.stat().st_mtime > built for p in [*(SOURCES / "Sources").glob("*.swift"),
|
||||
ROOT / "desktop" / "controller.c", ROOT / "desktop" / "controller.h"])
|
||||
return any(p.stat().st_mtime > built for p in (SOURCES / "Sources").glob("*.swift"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def agent_command(self):
|
||||
return [str(AGENT)]
|
||||
|
||||
def agent_environment(self):
|
||||
return {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
|
||||
|
||||
def ensure_agent(self):
|
||||
with self.lock:
|
||||
if self.agent and self.agent.poll() is None:
|
||||
@@ -192,11 +182,11 @@ class MacView:
|
||||
if reason:
|
||||
raise MacViewError(reason)
|
||||
self.build()
|
||||
env = self.agent_environment()
|
||||
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
|
||||
# The same port as before when restarting, so a running tunnel still
|
||||
# fits; otherwise (or if it's gone) whatever the system gives.
|
||||
for port in dict.fromkeys([self.port or 0, 0]):
|
||||
self.agent = subprocess.Popen([*self.agent_command(), "serve", "--port", str(port), "--page", str(PAGE),
|
||||
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
|
||||
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
line = self.agent.stdout.readline()
|
||||
@@ -205,7 +195,7 @@ class MacView:
|
||||
break
|
||||
self.agent.kill()
|
||||
else:
|
||||
raise MacViewError(f"The streaming helper didn't start: {line.strip() or 'no output'}")
|
||||
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
|
||||
if self.port != int(m.group(1)):
|
||||
self._drop_tunnel()
|
||||
self.port = int(m.group(1))
|
||||
@@ -221,7 +211,7 @@ class MacView:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.load(r)
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise MacViewError(f"The streaming helper didn't answer: {e}")
|
||||
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
|
||||
|
||||
# ---- the tunnel from the Frame ----
|
||||
|
||||
@@ -406,7 +396,7 @@ class MacView:
|
||||
# the agent no longer counts them, so only move when none are out there.
|
||||
others = self.shown - {src}
|
||||
self.ensure_tunnel(allow_new_port=not others)
|
||||
appid = panel_id(src, self.host)
|
||||
appid = panel_id(src)
|
||||
# Unique per launch, so a new window is never confused with an old one.
|
||||
tag = "fc" + secrets.token_hex(4)
|
||||
# A single-use ticket for this source, not Frame Control's key: the URL
|
||||
@@ -416,7 +406,7 @@ class MacView:
|
||||
"bpp": q["bpp"]}
|
||||
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
|
||||
w, h = fit(width or 1280, height or 720)
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags))
|
||||
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
|
||||
try:
|
||||
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
|
||||
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
|
||||
@@ -448,8 +438,7 @@ class MacView:
|
||||
if self.shown or self.shows != shows or self.launching:
|
||||
return
|
||||
try:
|
||||
pattern = "[f]rame-control/" + self.viewer_profile + "|[d]ata/frame-" + self.viewer_profile
|
||||
self.run("pkill -f " + _quote(pattern) + " || true", timeout=10)
|
||||
self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -460,8 +449,7 @@ class MacView:
|
||||
status = self.call("/status")
|
||||
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
|
||||
displays = self.call("/displays").get("displays", [])
|
||||
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
|
||||
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
|
||||
return {"available": True, "screen": status.get("screen", False),
|
||||
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
|
||||
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
|
||||
"route": self.route}
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame Control's Windows/Linux host. Same ticket, H.264/JPEG, timing and
|
||||
input protocol as frame-mac-view; serves the same ui/mac-view.html.
|
||||
|
||||
Only loopback is bound. Frame Control owns the master token, the Frame gets
|
||||
one-source tickets and reconnect keys. Native libraries are bundled.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from frame_pc_capture import Native, Controller, Windows, WindowsInput, PortalInput, Encoded, GATE, dimensions, pipeline
|
||||
from frame_stream_stats import Stats
|
||||
|
||||
|
||||
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
|
||||
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
|
||||
|
||||
|
||||
class Grants:
|
||||
"""Caller holds the agent lock, including replacement and Stop."""
|
||||
def __init__(self, token):
|
||||
self.token, self.tickets, self.keys = token, {}, {}
|
||||
|
||||
def master(self, key):
|
||||
return isinstance(key, str) and secrets.compare_digest(key, self.token)
|
||||
|
||||
def ticket(self, src):
|
||||
self.tickets = {k: v for k, v in self.tickets.items() if v[1] > time.monotonic()}
|
||||
if len(self.tickets) >= 128:
|
||||
raise ValueError('Too many pending viewers')
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
self.tickets[ticket] = (src, time.monotonic()+60, None)
|
||||
return ticket
|
||||
|
||||
def redeem(self, src, q):
|
||||
if self.master(q.get('k')):
|
||||
key = secrets.token_urlsafe(24)
|
||||
self.keys[key] = src
|
||||
return key
|
||||
entry = self.tickets.get(q.get('t'))
|
||||
if entry and entry[0] == src and entry[1] > time.monotonic():
|
||||
key = entry[2] or secrets.token_urlsafe(24)
|
||||
self.tickets[q['t']] = (src, entry[1], key)
|
||||
self.keys[key] = src
|
||||
return key
|
||||
key = q.get('r')
|
||||
return key if key and self.keys.get(key) == src else None
|
||||
|
||||
def ack(self, key):
|
||||
self.tickets = {k: v for k, v in self.tickets.items() if v[2] != key}
|
||||
|
||||
def revoke(self, src=None):
|
||||
self.tickets = {k: v for k, v in self.tickets.items() if src is not None and v[0] != src}
|
||||
self.keys = {k: v for k, v in self.keys.items() if src is not None and v != src}
|
||||
|
||||
|
||||
class WebSocket:
|
||||
def __init__(self, handler):
|
||||
self.sock, self.reader = handler.connection, handler.rfile
|
||||
self.lock = threading.Lock()
|
||||
self.closed = False
|
||||
self.sock.settimeout(5)
|
||||
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
|
||||
def send(self, data, opcode=1):
|
||||
if not isinstance(data, bytes):
|
||||
data = json.dumps(data, separators=(',', ':')).encode()
|
||||
n = len(data)
|
||||
head = bytes([0x80 | opcode, n]) if n < 126 else bytes([0x80 | opcode, 126]) + struct.pack('!H', n) if n <= 65535 else bytes([0x80 | opcode, 127]) + struct.pack('!Q', n)
|
||||
with self.lock:
|
||||
if self.closed:
|
||||
raise ConnectionError('Viewer disconnected')
|
||||
self.sock.sendall(head + data)
|
||||
|
||||
def exact(self, n):
|
||||
data = self.reader.read(n)
|
||||
if len(data) != n:
|
||||
raise ConnectionError('Viewer disconnected')
|
||||
return data
|
||||
|
||||
def receive(self):
|
||||
a, b = self.exact(2)
|
||||
opcode, size = a & 15, b & 127
|
||||
if a & 0x70 or not a & 0x80 or not b & 0x80 or opcode not in (1, 8, 9, 10):
|
||||
raise ValueError('Unsupported WebSocket frame')
|
||||
if size == 126:
|
||||
size = struct.unpack('!H', self.exact(2))[0]
|
||||
elif size == 127:
|
||||
size = struct.unpack('!Q', self.exact(8))[0]
|
||||
if size > 65536 or (opcode >= 8 and size > 125):
|
||||
raise ValueError('WebSocket message too large')
|
||||
mask = self.exact(4)
|
||||
data = bytes(v ^ mask[i % 4] for i, v in enumerate(self.exact(size)))
|
||||
if opcode == 8:
|
||||
raise ConnectionError('Viewer closed')
|
||||
if opcode == 9:
|
||||
self.send(data, 10)
|
||||
if opcode != 1:
|
||||
return {}
|
||||
message = json.loads(data)
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError('Expected an input object')
|
||||
return message
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, agent, ws, key, source, query):
|
||||
self.agent, self.native, self.ws, self.key, self.source = agent, agent.native, ws, key, source
|
||||
self.src = source['src']
|
||||
self.codec = query.get('codec', 'h264')
|
||||
if self.codec not in ('h264', 'jpeg'):
|
||||
raise ValueError('Unsupported codec')
|
||||
self.fps = min(120, max(5, int(query.get('fps', 60))))
|
||||
bpp = float(query.get('bpp', .1))
|
||||
if not math.isfinite(bpp):
|
||||
raise ValueError('Invalid bitrate')
|
||||
self.w, self.h = dimensions(source['w'], source['h'], min(3840, max(320, int(query.get('max', 1920)))))
|
||||
self.bitrate = max(300000, int(self.w*self.h*self.fps*min(.5, max(.02, bpp))))
|
||||
self.controller = Controller(self.native.lib, self.fps, self.bitrate)
|
||||
self.stats = Stats(self.native.now)
|
||||
self.stop_event, self.key_event = threading.Event(), threading.Event()
|
||||
self.lock, self.input_lock = threading.RLock(), threading.RLock()
|
||||
self.pending, self.last_submit = {}, 0
|
||||
self.reconfiguring = False
|
||||
self.test_inputs = queue.Queue(maxsize=128)
|
||||
self.input = None if self.src == 'test' else WindowsInput(agent.windows, source) if agent.windows else PortalInput(self.native, source)
|
||||
self.writer = None
|
||||
self.released = False
|
||||
self.input_enabled = self.src == 'test' or self.source.get('devices', 3) == 3
|
||||
|
||||
def gate(self, stage, pts, capture, arrived):
|
||||
# Exceptions cannot cross a ctypes callback boundary.
|
||||
try:
|
||||
with self.lock:
|
||||
if self.stop_event.is_set() or self.reconfiguring:
|
||||
return -1
|
||||
if stage == 1:
|
||||
if pts in self.pending:
|
||||
self.pending[pts]['e0'] = self.native.now()
|
||||
return 1
|
||||
if stage == 0:
|
||||
self.stats.captured += 1
|
||||
self.controller.call('capture', arrived)
|
||||
now = self.native.now()
|
||||
state = self.controller.state()
|
||||
if len(self.pending) >= 3 or now-self.last_submit < 1000000/state['fps'] or not self.controller.call('gate', now, int(stage == 0)):
|
||||
if stage == 0:
|
||||
self.stats.skipped += 1
|
||||
return 0
|
||||
self.pending[pts] = dict(cap=capture, arr=arrived, e0=now, tier=state['tier'], br=state['target'])
|
||||
self.last_submit = now
|
||||
return 1
|
||||
except Exception:
|
||||
self.stop_event.set()
|
||||
return -1
|
||||
|
||||
STALL = 10000000
|
||||
|
||||
def stalled(self, now, opened, captured_at_open):
|
||||
"""PipeWire and WGC deliver frames only on screen damage, so an idle
|
||||
desktop legitimately produces no output. Fail only on evidence of a
|
||||
fault: a frame entered the encoder and never came out, or the capture
|
||||
never produced its initial frame after the pipeline opened."""
|
||||
with self.lock:
|
||||
oldest = min((r['e0'] for r in self.pending.values()), default=None)
|
||||
captured = self.stats.captured
|
||||
if oldest is not None and now-oldest > self.STALL:
|
||||
return 'The encoder stopped producing frames; check the video encoder'
|
||||
if captured == captured_at_open and now-opened > self.STALL:
|
||||
return 'No frames captured for 10 seconds; check capture permissions'
|
||||
return None
|
||||
|
||||
def fresh_pipewire(self):
|
||||
if 'portal' not in self.source:
|
||||
return
|
||||
error = C.create_string_buffer(1024)
|
||||
fd = self.native.lib.fc_portal_refresh(self.source['portal'], error, len(error))
|
||||
if fd < 0:
|
||||
raise RuntimeError(error.value.decode(errors='replace'))
|
||||
self.source['fd'] = fd
|
||||
|
||||
def produce(self):
|
||||
native, capture = self.native.lib, None
|
||||
try:
|
||||
encoder = "x264enc" if self.src == "test" and self.native.has("x264enc") else self.agent.encoder
|
||||
self.fresh_pipewire()
|
||||
description = pipeline(self.source, sys.platform, encoder, self.w, self.h, self.fps, self.bitrate, self.codec)
|
||||
self.callback = GATE(self.gate)
|
||||
error = C.create_string_buffer(1024)
|
||||
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
|
||||
if not capture:
|
||||
raise RuntimeError(error.value.decode(errors='replace'))
|
||||
last_update = last_stats = self.native.now()
|
||||
last_config = opened = last_update
|
||||
captured_at_open = self.stats.captured
|
||||
applied = (self.w, self.h, self.bitrate)
|
||||
wanted = applied
|
||||
while not self.stop_event.is_set():
|
||||
while not self.test_inputs.empty():
|
||||
event = self.test_inputs.get_nowait()
|
||||
try:
|
||||
native.fc_capture_test(capture, int(event.get('i', 0)) & 0xffffffff)
|
||||
self.stats.input(event)
|
||||
except MALFORMED:
|
||||
pass # a bad probe field drops the event, not the stream
|
||||
output = Encoded()
|
||||
result = native.fc_capture_pull(capture, C.byref(output))
|
||||
now = self.native.now()
|
||||
if result < 0:
|
||||
raise RuntimeError(native.fc_capture_error(capture).decode(errors='replace'))
|
||||
if result:
|
||||
with self.lock:
|
||||
# At most three raw frames are in flight; no B-frames.
|
||||
# x264 offsets PTS, so match the FIFO encode order while
|
||||
# retaining the actual pre-encode capture timestamp.
|
||||
raw_pts = next(iter(self.pending), None)
|
||||
record = self.pending.get(raw_pts)
|
||||
if record is None:
|
||||
raise RuntimeError('Encoder changed frame timestamps; timing cannot be matched')
|
||||
data = C.string_at(output.data, output.size)
|
||||
f = self.stats.add(**record, e1=now, snd=now, b=len(data), k=output.key,
|
||||
w=output.width or self.w, h=output.height or self.h)
|
||||
self.controller.call('sent', f['s'], len(data)+17, now)
|
||||
self.ws.send(struct.pack('!BQII', output.key, max(0, f['cap']), f['s'], f['echo'])+data, 2)
|
||||
with self.lock:
|
||||
f['wire'] = self.native.now()
|
||||
self.pending.pop(raw_pts, None)
|
||||
stall = self.stalled(now, opened, captured_at_open)
|
||||
if stall:
|
||||
raise RuntimeError(stall)
|
||||
if self.key_event.is_set():
|
||||
self.key_event.clear()
|
||||
if self.codec == 'h264':
|
||||
native.fc_capture_key(capture)
|
||||
if now-last_update >= 100000:
|
||||
target = self.controller.update(now)
|
||||
state = self.controller.state()
|
||||
w, h = dimensions(self.w, self.h, max(320, int(max(self.w, self.h)*state['scale'])))
|
||||
if target and self.codec == 'h264' and encoder == 'x264enc':
|
||||
native.fc_capture_bitrate(capture, target)
|
||||
self.bitrate = target
|
||||
# Hardware properties are not uniformly mutable in PLAYING.
|
||||
# Drain then reopen the pipeline at a keyframe when its
|
||||
# budget changes materially. The portal fd/session stays
|
||||
# alive, so this does not bypass or repeat user consent.
|
||||
desired_bitrate = target or applied[2]
|
||||
bitrate_change = self.codec == 'h264' and encoder != 'x264enc' and abs(desired_bitrate-applied[2]) > applied[2]*.2
|
||||
if not self.reconfiguring and now-last_config >= 1000000 and ((w, h) != applied[:2] or bitrate_change):
|
||||
wanted = (w, h, desired_bitrate)
|
||||
with self.lock:
|
||||
self.reconfiguring = True
|
||||
last_update = now
|
||||
if self.reconfiguring:
|
||||
with self.lock:
|
||||
drained = not self.pending
|
||||
if drained:
|
||||
native.fc_capture_close(capture)
|
||||
capture = None
|
||||
self.fresh_pipewire()
|
||||
description = pipeline(self.source, sys.platform, encoder, wanted[0], wanted[1], self.fps, wanted[2], self.codec)
|
||||
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
|
||||
if not capture:
|
||||
raise RuntimeError(error.value.decode(errors='replace'))
|
||||
applied, self.bitrate, last_config = wanted, wanted[2], now
|
||||
opened, captured_at_open = now, self.stats.captured
|
||||
with self.lock:
|
||||
self.reconfiguring = False
|
||||
if now-last_stats >= 1000000:
|
||||
self.ws.send(dict(self.stats.summary(), t='stats', bitrate=self.bitrate,
|
||||
size='%dx%d' % (self.w, self.h), tier=self.controller.state()['tier']))
|
||||
last_stats = now
|
||||
except Exception as e:
|
||||
try:
|
||||
self.ws.send({'t': 'error', 'message': str(e)})
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
self.stop_event.set()
|
||||
if capture:
|
||||
native.fc_capture_close(capture)
|
||||
self.ws.close()
|
||||
|
||||
def start(self):
|
||||
if self.stop_event.is_set():
|
||||
self.controller.close()
|
||||
return
|
||||
self.ws.send(dict(t='hello', r=self.key))
|
||||
self.ws.send(dict(t='info', src=self.src, title=self.source.get('title', self.source.get('name', 'Test pattern')),
|
||||
app='PC', codec=self.codec, input=self.input_enabled,
|
||||
inputMessage='Allow pointer and keyboard control in the host sharing dialog.',
|
||||
aspect=self.w/self.h, warm=0))
|
||||
with self.lock:
|
||||
if self.stop_event.is_set():
|
||||
self.controller.close()
|
||||
return
|
||||
self.writer = threading.Thread(target=self.produce, daemon=True)
|
||||
self.writer.start()
|
||||
try:
|
||||
while not self.stop_event.is_set():
|
||||
m = self.ws.receive()
|
||||
t = m.get('t')
|
||||
if t == 'ping':
|
||||
self.ws.send(dict(t='pong', c=m.get('c', 0), a=self.native.now()))
|
||||
elif t == 'ack':
|
||||
with self.agent.lock:
|
||||
self.agent.grants.ack(self.key)
|
||||
elif t == 'key-frame':
|
||||
self.key_event.set()
|
||||
elif t in ('rx', 'fd', 'clock'):
|
||||
try:
|
||||
self.stats.report(m)
|
||||
except MALFORMED:
|
||||
pass
|
||||
if t == 'rx' and isinstance(m.get('s'), int):
|
||||
self.controller.call('ack', m['s'] & 0xffffffff, self.native.now())
|
||||
elif t in ('m', 'wheel', 'k', 'text', 'release'):
|
||||
with self.input_lock:
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
if self.input:
|
||||
if not self.input_enabled and t != 'release':
|
||||
continue
|
||||
try:
|
||||
self.input.handle(m)
|
||||
self.stats.input(m)
|
||||
except MALFORMED:
|
||||
pass # drop a malformed viewer event; keep the session
|
||||
except RuntimeError as e:
|
||||
self.input_enabled = False
|
||||
try:
|
||||
self.input.release()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.ws.send(dict(t='error', message=str(e)))
|
||||
elif m.get('i'):
|
||||
try:
|
||||
self.test_inputs.put_nowait(m)
|
||||
except queue.Full:
|
||||
pass
|
||||
finally:
|
||||
self.end()
|
||||
self.writer.join(6)
|
||||
if not self.writer.is_alive():
|
||||
self.controller.close()
|
||||
|
||||
def end(self):
|
||||
with self.lock:
|
||||
self.stop_event.set()
|
||||
self.ws.close()
|
||||
with self.input_lock:
|
||||
if self.input and not self.released:
|
||||
self.released = True
|
||||
try:
|
||||
self.input.release()
|
||||
except (RuntimeError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self, native, token, page):
|
||||
self.native, self.page = native, page
|
||||
self.lock = threading.RLock()
|
||||
self.grants = Grants(token)
|
||||
self.sessions, self.sources = {}, {}
|
||||
self.next_id, self.selecting, self.selection_error = 1, False, ''
|
||||
self.windows = Windows() if sys.platform == 'win32' else None
|
||||
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
|
||||
self.shutting_down = False
|
||||
self.selection_generation = 0
|
||||
|
||||
def lists(self):
|
||||
if self.windows:
|
||||
windows, displays = self.windows.sources()
|
||||
self.sources = {s['src']: s for s in windows + displays}
|
||||
return windows, displays
|
||||
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
|
||||
|
||||
def source(self, src):
|
||||
if src == 'test':
|
||||
return dict(src='test', title='Test pattern', w=1280, h=720)
|
||||
self.lists()
|
||||
if src not in self.sources:
|
||||
raise ValueError('Choose a window or screen on this computer first')
|
||||
return dict(self.sources[src])
|
||||
|
||||
def select(self):
|
||||
if self.windows or self.selecting or self.shutting_down:
|
||||
return
|
||||
if len(self.sources) >= 8:
|
||||
raise ValueError('Stop a panel before sharing another source')
|
||||
self.selecting, self.selection_error = True, ''
|
||||
generation = self.selection_generation
|
||||
def choose():
|
||||
error = C.create_string_buffer(1024)
|
||||
portal = self.native.lib.fc_portal_select(error, len(error))
|
||||
with self.lock:
|
||||
self.selecting = False
|
||||
if not portal:
|
||||
self.selection_error = error.value.decode(errors='replace')
|
||||
elif self.shutting_down or generation != self.selection_generation:
|
||||
self.native.lib.fc_portal_close(portal)
|
||||
else:
|
||||
fd, node, w, h, devices = [self.native.lib.fc_portal_value(portal, i) for i in range(5)]
|
||||
src = 'window:' + secrets.token_hex(8)
|
||||
self.sources[src] = dict(src=src, title='Shared window or screen', app='Linux portal',
|
||||
w=w, h=h, fd=fd, node=node, portal=portal, devices=devices)
|
||||
threading.Thread(target=choose, daemon=True).start()
|
||||
|
||||
def stop(self, src=None):
|
||||
with self.lock:
|
||||
if src is None:
|
||||
self.selection_generation += 1
|
||||
self.grants.revoke(src)
|
||||
sessions = [s for s in self.sessions.values() if src is None or s.src == src]
|
||||
for session in sessions:
|
||||
try:
|
||||
session.ws.send(dict(t='close'))
|
||||
except OSError:
|
||||
pass
|
||||
session.end()
|
||||
for session in sessions:
|
||||
if session.writer and session.writer is not threading.current_thread():
|
||||
session.writer.join(6)
|
||||
# The capture is stopped before releasing its PipeWire fd/session.
|
||||
with self.lock:
|
||||
if not self.windows:
|
||||
for key, source in list(self.sources.items()):
|
||||
if src is None or key == src:
|
||||
if any(s.src == key and s.writer and s.writer.is_alive() for s in sessions):
|
||||
continue
|
||||
self.native.lib.fc_portal_close(source['portal'])
|
||||
self.sources.pop(key, None)
|
||||
return {'closed': len(sessions)}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def log_message(self, *args):
|
||||
pass # URLs contain credentials
|
||||
|
||||
def reply(self, data, status=200, content='application/json'):
|
||||
if not isinstance(data, bytes):
|
||||
data = json.dumps(data).encode()
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', content)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.send_header('Connection', 'close')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
self.close_connection = True
|
||||
|
||||
def do_GET(self):
|
||||
self.dispatch('GET')
|
||||
|
||||
def do_POST(self):
|
||||
self.dispatch('POST')
|
||||
|
||||
def dispatch(self, method):
|
||||
agent = self.server.agent
|
||||
url = urlsplit(self.path)
|
||||
q = {k: v[-1] for k, v in parse_qs(url.query).items()}
|
||||
try:
|
||||
if method == 'GET' and url.path == '/ping':
|
||||
return self.reply(b'frame-mac-view', content='text/plain')
|
||||
if method == 'GET' and url.path == '/view':
|
||||
return self.reply(agent.page.read_bytes(), content='text/html; charset=utf-8')
|
||||
if method == 'GET' and url.path == '/stream':
|
||||
return self.stream(q)
|
||||
if not agent.grants.master(q.get('k', self.headers.get('X-Token'))):
|
||||
return self.reply({'error': 'forbidden'}, 403)
|
||||
if method == 'POST' and url.path == '/close':
|
||||
return self.reply(agent.stop(q.get('src')))
|
||||
with agent.lock:
|
||||
windows, displays = agent.lists()
|
||||
if method == 'GET' and url.path == '/status':
|
||||
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
|
||||
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
|
||||
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
|
||||
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
|
||||
for i, s in agent.sessions.items() if not s.stop_event.is_set()])
|
||||
elif method == 'GET' and url.path in ('/windows', '/displays'):
|
||||
data = dict(windows=windows, displays=displays, screen=True)
|
||||
elif method == 'POST' and url.path == '/ticket':
|
||||
agent.source(q.get('src'))
|
||||
data = {'ticket': agent.grants.ticket(q['src'])}
|
||||
elif method == 'POST' and url.path == '/permissions':
|
||||
agent.select()
|
||||
data = {'selecting': agent.selecting}
|
||||
elif method == 'GET' and url.path == '/stats':
|
||||
data = dict(now=agent.native.now(), streams=[dict(id=i, src=s.src, controller=s.controller.state(),
|
||||
events=list(s.controller.events), **s.stats.snapshot(max(0, int(q.get('since', 0))), max(0, int(q.get('settle', 1500000)))))
|
||||
for i, s in agent.sessions.items() if q.get('id', str(i)) == str(i) and not s.stop_event.is_set()])
|
||||
elif method == 'POST' and url.path == '/bench':
|
||||
data = {'sent': 0}
|
||||
for s in agent.sessions.values():
|
||||
if s.src == q.get('src'):
|
||||
m = {k: v for k, v in q.items() if k not in ('k', 'src')}
|
||||
for k in ('x', 'y', 'interval'):
|
||||
if k in m:
|
||||
m[k] = float(m[k])
|
||||
s.ws.send(dict(m, t='bench'))
|
||||
data['sent'] += 1
|
||||
else:
|
||||
return self.reply({'error': 'not found'}, 404)
|
||||
self.reply(data)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
self.reply({'error': str(e)}, 400)
|
||||
except (OSError, ConnectionError):
|
||||
self.close_connection = True
|
||||
|
||||
def stream(self, query):
|
||||
agent = self.server.agent
|
||||
with agent.lock:
|
||||
key = agent.grants.redeem(query.get('src'), query)
|
||||
if not key:
|
||||
return self.reply({'error': 'forbidden'}, 403)
|
||||
source = agent.source(query.get('src'))
|
||||
if self.headers.get('Upgrade', '').lower() != 'websocket' or self.headers.get('Sec-WebSocket-Version') != '13':
|
||||
return self.reply({'error': 'expected WebSocket'}, 400)
|
||||
wskey = self.headers.get('Sec-WebSocket-Key', '')
|
||||
if len(base64.b64decode(wskey, validate=True)) != 16:
|
||||
raise ValueError('Bad WebSocket key')
|
||||
if len(agent.sessions) >= 8:
|
||||
raise ValueError('At most eight panels may be open')
|
||||
# Stop can revoke and close only a registered session. Register
|
||||
# under the same lock as redemption, before any capture starts.
|
||||
session = Session(agent, WebSocket(self), key, source, query)
|
||||
for old in list(agent.sessions.values()):
|
||||
if old.key == key or old.src == source['src']:
|
||||
if old.key != key:
|
||||
agent.grants.keys.pop(old.key, None)
|
||||
try:
|
||||
old.ws.send(dict(t='close'))
|
||||
except OSError:
|
||||
pass
|
||||
old.end()
|
||||
if old.writer:
|
||||
old.writer.join(6)
|
||||
if old.writer.is_alive():
|
||||
session.controller.close()
|
||||
raise ValueError('The previous capture is still stopping; retry shortly')
|
||||
ident = agent.next_id
|
||||
agent.next_id += 1
|
||||
agent.sessions[ident] = session
|
||||
accept = base64.b64encode(hashlib.sha1((wskey+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
|
||||
self.send_response(101)
|
||||
self.send_header('Upgrade', 'websocket')
|
||||
self.send_header('Connection', 'Upgrade')
|
||||
self.send_header('Sec-WebSocket-Accept', accept)
|
||||
self.end_headers()
|
||||
try:
|
||||
session.start()
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
session.end()
|
||||
finally:
|
||||
session.end()
|
||||
if session.writer:
|
||||
session.writer.join(6)
|
||||
if not session.writer or not session.writer.is_alive():
|
||||
session.controller.close()
|
||||
with agent.lock:
|
||||
agent.sessions.pop(ident, None)
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('command', choices=['serve'])
|
||||
parser.add_argument('--port', type=int, default=0)
|
||||
parser.add_argument('--page', type=Path, required=True)
|
||||
parser.add_argument('--exit-on-eof', action='store_true')
|
||||
args = parser.parse_args()
|
||||
token = os.environ.get('FRAME_MAC_VIEW_TOKEN')
|
||||
if not token:
|
||||
raise SystemExit('Frame Control must provide a private token')
|
||||
native = Native()
|
||||
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
|
||||
agent = server.agent = Agent(native, token, args.page)
|
||||
if args.exit_on_eof:
|
||||
def eof():
|
||||
sys.stdin.buffer.read()
|
||||
with agent.lock:
|
||||
agent.shutting_down = True
|
||||
agent.stop()
|
||||
server.shutdown()
|
||||
threading.Thread(target=eof, daemon=True).start()
|
||||
print('listening on 127.0.0.1:%d' % server.server_port, flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
agent.shutting_down = True
|
||||
agent.stop()
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, RuntimeError, AttributeError) as e:
|
||||
raise SystemExit('PC streaming helper: ' + str(e))
|
||||
@@ -1,383 +0,0 @@
|
||||
"""Native PC capture and input bindings. No desktop-streaming app required.
|
||||
|
||||
The packaged native library contains our adapter and the shared rate controller;
|
||||
GStreamer supplies WGC/Media Foundation and PipeWire/VA-API/x264 libraries.
|
||||
"""
|
||||
import ctypes as C
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NATIVE = Path(os.environ.get('FRAME_PC_NATIVE', ROOT / 'desktop' / 'bundle'))
|
||||
LIBRARY = NATIVE / ('pc-host.dll' if sys.platform == 'win32' else 'pc-host.so')
|
||||
|
||||
|
||||
class Encoded(C.Structure):
|
||||
_fields_ = [('data', C.c_void_p), ('size', C.c_int), ('key', C.c_int),
|
||||
('width', C.c_int), ('height', C.c_int), ('pts', C.c_int64)]
|
||||
|
||||
|
||||
GATE = C.CFUNCTYPE(C.c_int, C.c_int, C.c_int64, C.c_int64, C.c_int64)
|
||||
|
||||
|
||||
class Native:
|
||||
def __init__(self, path=LIBRARY):
|
||||
self.dll_dirs = []
|
||||
if sys.platform == 'win32':
|
||||
# Set process coordinates before loading GStreamer: a plugin may
|
||||
# create a helper HWND during discovery, after which it is too late.
|
||||
user = C.WinDLL('user32')
|
||||
user.SetProcessDpiAwarenessContext.argtypes = [C.c_void_p]
|
||||
user.SetProcessDpiAwarenessContext(C.c_void_p(-4))
|
||||
for folder in (path.parent, path.parent / 'bin'):
|
||||
self.dll_dirs.append(os.add_dll_directory(str(folder)))
|
||||
self.lib = C.CDLL(str(path))
|
||||
signatures = {
|
||||
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
|
||||
'fc_has_element': (C.c_int, [C.c_char_p]),
|
||||
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
|
||||
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
|
||||
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
'fc_capture': (None, [C.c_void_p, C.c_int64]),
|
||||
'fc_sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
|
||||
'fc_ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]),
|
||||
'fc_update': (C.c_int, [C.c_void_p, C.c_int64]),
|
||||
'fc_value': (C.c_int64, [C.c_void_p, C.c_int]),
|
||||
'fc_capture_open': (C.c_void_p, [C.c_char_p, GATE, C.c_char_p, C.c_int]),
|
||||
'fc_capture_pull': (C.c_int, [C.c_void_p, C.POINTER(Encoded)]),
|
||||
'fc_capture_error': (C.c_char_p, [C.c_void_p]),
|
||||
'fc_capture_bitrate': (None, [C.c_void_p, C.c_int]),
|
||||
'fc_capture_key': (None, [C.c_void_p]),
|
||||
'fc_capture_test': (None, [C.c_void_p, C.c_uint32]), 'fc_capture_close': (None, [C.c_void_p]),
|
||||
}
|
||||
if sys.platform.startswith('linux'):
|
||||
signatures.update({
|
||||
'fc_portal_select': (C.c_void_p, [C.c_char_p, C.c_int]),
|
||||
'fc_portal_close': (None, [C.c_void_p]),
|
||||
'fc_portal_refresh': (C.c_int, [C.c_void_p, C.c_char_p, C.c_int]),
|
||||
'fc_portal_value': (C.c_int, [C.c_void_p, C.c_int]),
|
||||
'fc_portal_input': (C.c_int, [C.c_void_p, C.c_int, C.c_double, C.c_double, C.c_int, C.c_int]),
|
||||
})
|
||||
for name, (result, args) in signatures.items():
|
||||
fn = getattr(self.lib, name)
|
||||
fn.restype, fn.argtypes = result, args
|
||||
self.lib.fc_gst_init()
|
||||
|
||||
def has(self, name):
|
||||
return bool(self.lib.fc_has_element(name.encode()))
|
||||
|
||||
def now(self):
|
||||
return self.lib.fc_now()
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, lib, fps, ceiling):
|
||||
# close() may run on the stream thread while /status reads state();
|
||||
# after close every entry point is a no-op, never a NULL dereference.
|
||||
self.lib, self.lock, self.fps = lib, threading.RLock(), fps
|
||||
self.enabled = os.environ.get('FRAME_MAC_VIEW_ADAPT') != '0'
|
||||
self.ptr = lib.fc_new(fps, self.enabled)
|
||||
if not self.ptr:
|
||||
raise MemoryError('Unable to allocate the streaming controller')
|
||||
lib.fc_ceiling(self.ptr, ceiling)
|
||||
self.events = []
|
||||
|
||||
def state(self):
|
||||
with self.lock:
|
||||
names = ['target', 'ceiling', 'tier', 'fps', 'scale', 'baseRtt', 'inFlight', 'slack']
|
||||
if not self.ptr:
|
||||
out = dict.fromkeys(names, 0)
|
||||
out.update(fps=self.fps, scale=100)
|
||||
else:
|
||||
out = {k: self.lib.fc_value(self.ptr, i) for i, k in enumerate(names)}
|
||||
out.update(scale=out['scale'] / 100, baseRtt=out['baseRtt'] / 1000,
|
||||
slack=out['slack'] / 1000, adapt=self.enabled)
|
||||
return out
|
||||
|
||||
def call(self, name, *args):
|
||||
with self.lock:
|
||||
return getattr(self.lib, 'fc_' + name)(self.ptr, *args) if self.ptr else 0
|
||||
|
||||
def update(self, now):
|
||||
with self.lock:
|
||||
if not self.ptr:
|
||||
return 0
|
||||
old = self.state()
|
||||
result = self.lib.fc_update(self.ptr, now)
|
||||
state = self.state()
|
||||
if state['target'] < old['target'] or state['tier'] != old['tier']:
|
||||
self.events.append({'t': now, 'e': 'target %s bit/s; tier %s' % (state['target'], state['tier'])})
|
||||
self.events = self.events[-200:]
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
if self.ptr:
|
||||
self.lib.fc_free(self.ptr)
|
||||
self.ptr = None
|
||||
|
||||
|
||||
def dimensions(w, h, maximum):
|
||||
scale = min(1, maximum / max(w, h))
|
||||
return max(2, int(w * scale) // 2 * 2), max(2, int(h * scale) // 2 * 2)
|
||||
|
||||
|
||||
def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
|
||||
"""Only locally constructed numeric source IDs enter the pipeline parser."""
|
||||
if source['src'] == 'test':
|
||||
capture = 'videotestsrc name=source is-live=true pattern=ball'
|
||||
elif platform == 'win32':
|
||||
kind, ident = source['src'].split(':')
|
||||
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
|
||||
raise ValueError('Invalid Windows capture source')
|
||||
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
|
||||
capture = 'd3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true %s=%d' % (prop, int(ident))
|
||||
else:
|
||||
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
|
||||
# The source gate runs before conversion/encoding. There is no leaky queue
|
||||
# of H.264 frames; every encoded reference frame reaches the socket.
|
||||
raw = '%s ! video/x-raw,framerate=%d/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=%d,height=%d' % (capture, fps, w, h)
|
||||
if codec == 'jpeg':
|
||||
enc = 'jpegenc name=enc quality=80'
|
||||
parse = ''
|
||||
else:
|
||||
choices = {
|
||||
'mfh264enc': 'mfh264enc name=enc low-latency=true bframes=0 gop-size=60',
|
||||
'vah264enc': 'vah264enc name=enc b-frames=0 key-int-max=60',
|
||||
'x264enc': 'x264enc name=enc tune=zerolatency speed-preset=ultrafast bframes=0 key-int-max=60',
|
||||
}
|
||||
enc = choices[encoder] + ' bitrate=%d' % max(1, bitrate // 1000)
|
||||
raw += ',format=NV12' if encoder != 'x264enc' else ',format=I420'
|
||||
parse = ' ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au'
|
||||
return raw + ' ! ' + enc + parse + ' ! appsink name=out sync=false max-buffers=2 drop=false'
|
||||
|
||||
|
||||
# X11 keysyms work on Wayland through the RemoteDesktop portal too. The host's
|
||||
# keyboard layout handles physical keys; Unicode text is sent as Unicode keysyms.
|
||||
KEYSYMS = {'Enter': 0xff0d, 'Tab': 0xff09, 'Backspace': 0xff08, 'Escape': 0xff1b,
|
||||
'Delete': 0xffff, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52, 'ArrowRight': 0xff53,
|
||||
'ArrowDown': 0xff54, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
|
||||
'PageDown': 0xff56, 'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffeb}
|
||||
|
||||
|
||||
class PortalInput:
|
||||
def __init__(self, native, source):
|
||||
self.lib, self.source = native.lib, source
|
||||
self.buttons, self.keys = set(), set()
|
||||
|
||||
def emit(self, kind, x=0, y=0, code=0, down=0):
|
||||
if not self.lib.fc_portal_input(self.source['portal'], kind, x, y, code, down):
|
||||
raise RuntimeError('The desktop portal refused input; check the sharing permission')
|
||||
|
||||
def release(self):
|
||||
error = None
|
||||
for values, kind in ((self.buttons, 1), (self.keys, 3)):
|
||||
for code in list(values):
|
||||
try:
|
||||
self.emit(kind, code=code, down=0)
|
||||
values.discard(code)
|
||||
except RuntimeError as e:
|
||||
error = e
|
||||
if error:
|
||||
raise error
|
||||
|
||||
def handle(self, m):
|
||||
t = m['t']
|
||||
if t == 'release':
|
||||
return self.release()
|
||||
if t in ('m', 'wheel'):
|
||||
x, y = (max(0, min(1, float(m.get(k, 0)))) for k in ('x', 'y'))
|
||||
self.emit(0, x * (self.source['w'] - 1), y * (self.source['h'] - 1))
|
||||
if t == 'm' and m.get('e') in ('up', 'down'):
|
||||
b = {0: 0x110, 1: 0x112, 2: 0x111}.get(m.get('b', 0))
|
||||
if b is not None:
|
||||
down = m['e'] == 'down'
|
||||
self.emit(1, code=b, down=down)
|
||||
(self.buttons.add if down else self.buttons.discard)(b)
|
||||
elif t == 'wheel':
|
||||
self.emit(2, max(-4096, min(4096, float(m.get('dx', 0)))), max(-4096, min(4096, float(m.get('dy', 0)))))
|
||||
elif t == 'k':
|
||||
key = str(m.get('key', ''))
|
||||
k = KEYSYMS.get(key) or (ord(key) if len(key) == 1 and ord(key) < 256 else
|
||||
0x1000000 + ord(key) if len(key) == 1 else 0)
|
||||
if k:
|
||||
down = m.get('e') == 'down'
|
||||
self.emit(3, code=k, down=down)
|
||||
(self.keys.add if down else self.keys.discard)(k)
|
||||
elif t == 'text':
|
||||
for ch in str(m.get('s', ''))[:4096]:
|
||||
k = ord(ch) if ord(ch) < 256 else 0x1000000 + ord(ch)
|
||||
self.emit(3, code=k, down=1)
|
||||
self.emit(3, code=k, down=0)
|
||||
|
||||
|
||||
class Windows:
|
||||
"""WGC source handles and SendInput. No elevation or global input hook."""
|
||||
def __init__(self):
|
||||
from ctypes import wintypes as W
|
||||
self.W = W
|
||||
self.user = C.WinDLL('user32', use_last_error=True)
|
||||
self.dwm = C.WinDLL('dwmapi')
|
||||
self.user.IsWindow.argtypes = [W.HWND]
|
||||
self.user.IsWindowVisible.argtypes = [W.HWND]
|
||||
self.user.IsIconic.argtypes = [W.HWND]
|
||||
self.user.GetWindowTextLengthW.argtypes = [W.HWND]
|
||||
self.user.GetWindowTextW.argtypes = [W.HWND, W.LPWSTR, C.c_int]
|
||||
self.user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
|
||||
self.user.SetForegroundWindow.argtypes = [W.HWND]
|
||||
self.user.GetForegroundWindow.argtypes = []
|
||||
self.user.GetForegroundWindow.restype = W.HWND
|
||||
self.dwm.DwmGetWindowAttribute.argtypes = [W.HWND, W.DWORD, C.c_void_p, W.DWORD]
|
||||
self.callback = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
|
||||
self.monitor_callback = C.WINFUNCTYPE(W.BOOL, W.HMONITOR, W.HDC, C.POINTER(W.RECT), W.LPARAM)
|
||||
self.user.EnumWindows.argtypes = [self.callback, W.LPARAM]
|
||||
self.user.EnumDisplayMonitors.argtypes = [W.HDC, C.c_void_p, self.monitor_callback, W.LPARAM]
|
||||
class Mouse(C.Structure):
|
||||
_fields_ = [('dx', W.LONG), ('dy', W.LONG), ('data', W.DWORD), ('flags', W.DWORD),
|
||||
('time', W.DWORD), ('extra', C.c_size_t)]
|
||||
class Key(C.Structure):
|
||||
_fields_ = [('vk', W.WORD), ('scan', W.WORD), ('flags', W.DWORD), ('time', W.DWORD), ('extra', C.c_size_t)]
|
||||
class Union(C.Union):
|
||||
_fields_ = [('mouse', Mouse), ('key', Key)]
|
||||
class Input(C.Structure):
|
||||
_fields_ = [('type', W.DWORD), ('data', Union)]
|
||||
self.Mouse, self.Key, self.Input = Mouse, Key, Input
|
||||
self.user.SendInput.argtypes = [W.UINT, C.POINTER(Input), C.c_int]
|
||||
self.user.SendInput.restype = W.UINT
|
||||
|
||||
def rect(self, hwnd):
|
||||
r = self.W.RECT()
|
||||
if not self.user.IsWindow(hwnd) or self.user.IsIconic(hwnd):
|
||||
raise RuntimeError('The captured window closed or was minimized')
|
||||
# WGC captures the visible extended frame, excluding invisible resize borders.
|
||||
if self.dwm.DwmGetWindowAttribute(hwnd, 9, C.byref(r), C.sizeof(r)):
|
||||
if not self.user.GetWindowRect(hwnd, C.byref(r)):
|
||||
raise RuntimeError('Cannot locate the captured window')
|
||||
return r.left, r.top, r.right - r.left, r.bottom - r.top
|
||||
|
||||
def sources(self):
|
||||
windows, displays = [], []
|
||||
def window(hwnd, _):
|
||||
if not self.user.IsWindowVisible(hwnd) or self.user.IsIconic(hwnd):
|
||||
return True
|
||||
n = self.user.GetWindowTextLengthW(hwnd)
|
||||
if n:
|
||||
title = C.create_unicode_buffer(n + 1)
|
||||
self.user.GetWindowTextW(hwnd, title, n + 1)
|
||||
try:
|
||||
x, y, w, h = self.rect(hwnd)
|
||||
if w > 0 and h > 0:
|
||||
windows.append(dict(src='window:%d' % hwnd, id=hwnd, title=title.value,
|
||||
app='Windows', w=w, h=h, x=x, y=y))
|
||||
except RuntimeError:
|
||||
pass
|
||||
return True
|
||||
def monitor(handle, dc, rect, data):
|
||||
r = rect.contents
|
||||
displays.append(dict(src='display:%d' % handle, name='Display %d' % (len(displays) + 1),
|
||||
x=r.left, y=r.top, w=r.right-r.left, h=r.bottom-r.top))
|
||||
return True
|
||||
self.user.EnumWindows(self.callback(window), 0)
|
||||
self.user.EnumDisplayMonitors(None, None, self.monitor_callback(monitor), 0)
|
||||
return windows, displays
|
||||
|
||||
def send(self, mouse=None, key=None):
|
||||
inp = self.Input()
|
||||
inp.type = 0 if mouse else 1
|
||||
if mouse:
|
||||
inp.data.mouse = self.Mouse(*mouse, 0, 0)
|
||||
else:
|
||||
inp.data.key = self.Key(*key, 0, 0)
|
||||
if self.user.SendInput(1, C.byref(inp), C.sizeof(inp)) != 1:
|
||||
raise RuntimeError('Windows refused input (elevated apps and the secure desktop cannot be controlled)')
|
||||
|
||||
|
||||
class WindowsInput:
|
||||
def __init__(self, host, source):
|
||||
self.host, self.source = host, source
|
||||
self.buttons, self.keys = set(), set()
|
||||
|
||||
def release(self):
|
||||
error = None
|
||||
for button in list(self.buttons):
|
||||
try:
|
||||
self.host.send(mouse=(0, 0, 0, {0: 4, 1: 0x40, 2: 0x10}[button]))
|
||||
self.buttons.discard(button)
|
||||
except RuntimeError as e:
|
||||
error = e
|
||||
for vk in list(self.keys):
|
||||
try:
|
||||
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
|
||||
self.host.send(key=(vk, 0, 2 | extended))
|
||||
self.keys.discard(vk)
|
||||
except RuntimeError as e:
|
||||
error = e
|
||||
if error:
|
||||
raise error
|
||||
|
||||
def focus(self):
|
||||
if not self.source['src'].startswith('window:'):
|
||||
return
|
||||
hwnd = int(self.source['src'].split(':')[1])
|
||||
user = self.host.user
|
||||
if user.GetForegroundWindow() != hwnd and not user.SetForegroundWindow(hwnd):
|
||||
raise RuntimeError('Windows could not focus this window. Bring it to the front on your PC, then press Stop and Show again.')
|
||||
if user.GetForegroundWindow() != hwnd:
|
||||
raise RuntimeError('The selected window is not in front. Reopen the view after bringing it to the front on your PC.')
|
||||
|
||||
def handle(self, m):
|
||||
t, u = m['t'], self.host.user
|
||||
if t == 'release':
|
||||
return self.release()
|
||||
if t in ('m', 'wheel'):
|
||||
source = self.source
|
||||
if source['src'].startswith('window:'):
|
||||
hwnd = int(source['src'].split(':')[1])
|
||||
x, y, w, h = self.host.rect(hwnd)
|
||||
if m.get('e') == 'down' or t == 'wheel':
|
||||
self.focus()
|
||||
else:
|
||||
x, y, w, h = (source[k] for k in ('x', 'y', 'w', 'h'))
|
||||
x += max(0, min(1, float(m.get('x', 0)))) * (w - 1)
|
||||
y += max(0, min(1, float(m.get('y', 0)))) * (h - 1)
|
||||
left, top, width, height = (u.GetSystemMetrics(i) for i in (76, 77, 78, 79))
|
||||
self.host.send(mouse=(round((x-left)*65535/max(1,width-1)), round((y-top)*65535/max(1,height-1)), 0, 0xc001))
|
||||
if t == 'm' and m.get('e') in ('up', 'down'):
|
||||
b = m.get('b', 0)
|
||||
if b in (0, 1, 2):
|
||||
down = m['e'] == 'down'
|
||||
self.host.send(mouse=(0, 0, 0, {0: 2, 1: 0x20, 2: 8}[b] * (1 if down else 2)))
|
||||
(self.buttons.add if down else self.buttons.discard)(b)
|
||||
elif t == 'wheel':
|
||||
for name, flag, direction in (('dy', 0x800, -1), ('dx', 0x1000, 1)):
|
||||
delta = int(max(-4096, min(4096, float(m.get(name, 0))))) * direction
|
||||
if delta:
|
||||
self.host.send(mouse=(0, 0, delta & 0xffffffff, flag))
|
||||
elif t == 'k':
|
||||
code, down = str(m.get('code', '')), m.get('e') == 'down'
|
||||
if down:
|
||||
self.focus()
|
||||
special = {'Enter': 13, 'Escape': 27, 'Tab': 9, 'Backspace': 8, 'Space': 32,
|
||||
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
|
||||
'Delete': 46, 'Home': 36, 'End': 35, 'PageUp': 33, 'PageDown': 34,
|
||||
'ShiftLeft': 160, 'ShiftRight': 161, 'ControlLeft': 162, 'ControlRight': 163,
|
||||
'AltLeft': 164, 'AltRight': 165, 'MetaLeft': 91, 'MetaRight': 92}
|
||||
vk = special.get(code, 0)
|
||||
if re.fullmatch(r'Key[A-Z]', code) or re.fullmatch(r'Digit[0-9]', code):
|
||||
vk = ord(code[-1])
|
||||
if vk:
|
||||
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
|
||||
self.host.send(key=(vk, 0, extended | (0 if down else 2)))
|
||||
(self.keys.add if down else self.keys.discard)(vk)
|
||||
elif down and len(str(m.get('key', ''))) == 1:
|
||||
self.handle({'t': 'text', 's': m['key']})
|
||||
elif t == 'text':
|
||||
self.focus()
|
||||
raw = str(m.get('s', ''))[:4096].encode('utf-16-le')
|
||||
for i in range(0, len(raw), 2):
|
||||
unit = int.from_bytes(raw[i:i+2], 'little')
|
||||
self.host.send(key=(0, unit, 4))
|
||||
self.host.send(key=(0, unit, 6))
|
||||
@@ -1,90 +0,0 @@
|
||||
"""PC host selection behind the existing MacView tunnel/panel controller.
|
||||
|
||||
The public /api/macview name and viewer URL remain compatible with the Mac
|
||||
base branch. Only helper launch, platform availability and source validation
|
||||
are different. No second viewer, SSH supervisor or benchmark launcher.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from frame_macview import MacView, MacViewError, ROOT
|
||||
from frame_pc_capture import LIBRARY, NATIVE
|
||||
|
||||
|
||||
def prepend(env, name, path):
|
||||
"""An empty entry means the current directory to the loader; never add one."""
|
||||
rest = env.get(name, '')
|
||||
env[name] = str(path) + (os.pathsep + rest if rest else '')
|
||||
|
||||
|
||||
class PCView(MacView):
|
||||
viewer_profile = 'pc-view'
|
||||
host = 'windows' if sys.platform == 'win32' else 'linux'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.prefer_usb = False # Mac networksetup probe is platform-specific
|
||||
|
||||
def unavailable(self):
|
||||
if sys.platform not in ('win32', 'linux'):
|
||||
return 'PC streaming needs Windows or a Linux desktop.'
|
||||
if not LIBRARY.is_file():
|
||||
return 'The PC streaming libraries are missing from this build. See docs/pc-in-headset.md.'
|
||||
return None
|
||||
|
||||
def state(self):
|
||||
result = super().state()
|
||||
result["host"] = self.host
|
||||
return result
|
||||
|
||||
def build(self):
|
||||
pass # native libraries are built and bundled with the app
|
||||
|
||||
def agent_command(self):
|
||||
return [sys.executable, str(ROOT / 'ui' / 'frame_pc_agent.py')]
|
||||
|
||||
def agent_environment(self):
|
||||
env = super().agent_environment()
|
||||
env['GST_PLUGIN_PATH_1_0'] = str(NATIVE / 'lib' / 'gstreamer-1.0')
|
||||
env['GST_PLUGIN_SYSTEM_PATH_1_0'] = ''
|
||||
cache = os.path.join(os.path.expanduser('~'), '.cache', 'frame-control')
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
env['GST_REGISTRY_1_0'] = os.path.join(cache, 'gstreamer-registry.bin')
|
||||
env['GST_REGISTRY_FORK'] = 'no'
|
||||
if sys.platform == 'win32':
|
||||
prepend(env, 'PATH', NATIVE / 'bin')
|
||||
else:
|
||||
prepend(env, 'LD_LIBRARY_PATH', NATIVE / 'lib')
|
||||
env['PIPEWIRE_MODULE_DIR'] = str(NATIVE / 'lib' / 'pipewire-0.3')
|
||||
env['SPA_PLUGIN_DIR'] = str(NATIVE / 'lib' / 'spa-0.2')
|
||||
env['PIPEWIRE_CONFIG_DIR'] = str(NATIVE / 'share' / 'pipewire')
|
||||
env['PIPEWIRE_CONFIG_NAME'] = 'client.conf'
|
||||
return env
|
||||
|
||||
def shutdown(self):
|
||||
self.closing = True
|
||||
try:
|
||||
self.stop()
|
||||
except MacViewError:
|
||||
pass
|
||||
if self.tunnel and self.tunnel.poll() is None:
|
||||
self.tunnel.terminate()
|
||||
if self.agent and self.agent.poll() is None:
|
||||
# EOF gives the host a chance to release held input and portal
|
||||
# sessions, including on Windows where terminate is uncatchable.
|
||||
if self.agent.stdin:
|
||||
self.agent.stdin.close()
|
||||
try:
|
||||
self.agent.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.agent.kill()
|
||||
self.agent.wait(5)
|
||||
|
||||
def _show(self, src, quality, width, height):
|
||||
if src.startswith('separate:'):
|
||||
raise MacViewError('Separate virtual displays are a Mac-only feature. Choose a window or screen.')
|
||||
return super()._show(src, quality, width, height)
|
||||
|
||||
|
||||
def host_view(*args, **kwargs):
|
||||
return (MacView if sys.platform == 'darwin' else PCView)(*args, **kwargs)
|
||||
@@ -1,88 +0,0 @@
|
||||
"""The desktop stream's timing records, in the Mac viewer/bench schema.
|
||||
|
||||
Times are host monotonic microseconds. Zero means unknown, never a fabricated
|
||||
capture/display timestamp. Storage is bounded to the same 4096/512 records as
|
||||
Stats.swift. The existing macview-bench.py grades these records unchanged.
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import threading
|
||||
|
||||
|
||||
def distribution(values):
|
||||
values = sorted(v for v in values if v is not None)
|
||||
if not values:
|
||||
return {}
|
||||
return {key: round(values[min(len(values)-1, int((len(values)-1)*p + .5))], 1)
|
||||
for key, p in (('p50', .5), ('p95', .95))}
|
||||
|
||||
|
||||
class Stats:
|
||||
def __init__(self, now):
|
||||
self.now, self.lock = now, threading.RLock()
|
||||
self.frames, self.inputs = OrderedDict(), OrderedDict()
|
||||
self.captured = self.skipped = self.dropped = 0
|
||||
self.rtt, self.decoder, self.synced = 0, '', False
|
||||
self.sequence = 0
|
||||
|
||||
def add(self, **values):
|
||||
with self.lock:
|
||||
self.sequence = (self.sequence + 1) & 0xffffffff
|
||||
record = dict.fromkeys(('s', 'k', 'b', 'w', 'h', 'cap', 'arr', 'e0', 'e1', 'snd',
|
||||
'wire', 'rx', 'dec', 'drw', 'vs', 'echo', 'tier', 'br'), 0)
|
||||
record.update(values, s=self.sequence)
|
||||
self.frames[self.sequence] = record
|
||||
while len(self.frames) > 4096:
|
||||
self.frames.popitem(last=False)
|
||||
for event in self.inputs.values():
|
||||
if not event['frame'] and event['inj'] <= record['cap']:
|
||||
event.update(frame=self.sequence, cap=record['cap'])
|
||||
record['echo'] = event['id']
|
||||
return record
|
||||
|
||||
def report(self, message):
|
||||
with self.lock:
|
||||
if message['t'] == 'rx':
|
||||
frame = self.frames.get(message.get('s'))
|
||||
if frame and isinstance(message.get('r'), (int, float)):
|
||||
frame['rx'] = int(message['r'])
|
||||
elif message['t'] == 'fd':
|
||||
for item in message.get('f', [])[:4096]:
|
||||
if isinstance(item, list) and len(item) >= 4:
|
||||
frame = self.frames.get(item[0])
|
||||
if frame:
|
||||
for name, val in zip(('dec', 'drw', 'vs'), item[1:4]):
|
||||
if isinstance(val, (int, float)):
|
||||
frame[name] = int(val)
|
||||
self.dropped += max(0, int(message.get('drop', 0)))
|
||||
elif message['t'] == 'clock':
|
||||
self.rtt = float(message.get('rtt', 0))
|
||||
self.decoder = str(message.get('dec', ''))[:1024]
|
||||
self.synced = True
|
||||
|
||||
def input(self, m):
|
||||
if not isinstance(m.get('i'), int) or not m['i']:
|
||||
return
|
||||
with self.lock:
|
||||
self.inputs[m['i']] = dict(id=m['i'], kind=m['t'], tv=m.get('tv') or 0,
|
||||
inj=self.now(), frame=0, cap=0)
|
||||
while len(self.inputs) > 512:
|
||||
self.inputs.popitem(last=False)
|
||||
|
||||
def summary(self):
|
||||
with self.lock:
|
||||
now = self.now()
|
||||
frames = [f for f in self.frames.values() if f['cap'] >= now-2000000]
|
||||
out = dict(captured=self.captured, skipped=self.skipped, dropped=self.dropped,
|
||||
rtt=self.rtt, decoder=self.decoder, synced=self.synced,
|
||||
fps=sum(f['vs'] > 0 for f in frames)/2, sentFps=len(frames)/2,
|
||||
mbps=round(sum(f['b'] for f in frames)*4/1e6, 2))
|
||||
for key, start, end in (('capture', 'cap', 'arr'), ('queue', 'arr', 'e0'),
|
||||
('encode', 'e0', 'e1'), ('network', 'e1', 'rx'),
|
||||
('decode', 'rx', 'dec'), ('draw', 'dec', 'drw'), ('total', 'cap', 'drw')):
|
||||
out[key] = distribution([(f[end]-f[start])/1000 for f in frames if f[start] and f[end]])
|
||||
return out
|
||||
|
||||
def snapshot(self, since=0, settle=1500000):
|
||||
with self.lock:
|
||||
return dict(frames=[dict(f) for f in self.frames.values() if f['s'] > since and f['cap'] <= self.now()-settle],
|
||||
inputs=[dict(i) for i in self.inputs.values()], captured=self.captured, summary=self.summary())
|
||||
+1286
-446
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -22,7 +22,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c" width="16" height="9"></canvas>
|
||||
<div id="note">Connecting to your computer…</div>
|
||||
<div id="note">Connecting to the Mac…</div>
|
||||
<div id="hud" hidden></div>
|
||||
<script>
|
||||
"use strict";
|
||||
@@ -243,14 +243,14 @@ async function connect() {
|
||||
// Refused before ever getting a key: the ticket expired or was revoked,
|
||||
// and retrying can't help.
|
||||
if (!reconnectKey && ++failedStarts >= 3) {
|
||||
show("This view has expired. Press Show in Frame Control on your computer to open it again.");
|
||||
show("This view has expired. Press Show in Frame Control on the Mac to open it again.");
|
||||
return;
|
||||
}
|
||||
hideNoteOnFrame = true;
|
||||
// The Mac may be asleep or the tunnel restarting: keep trying.
|
||||
retry = Math.min(retry + 1, 6);
|
||||
// Keep the Mac's reason (a missing permission, a closed window) on screen.
|
||||
show(lastError ? `${lastError} Retrying…` : "Lost the computer. Reconnecting…");
|
||||
show(lastError ? `${lastError} Retrying…` : "Lost the Mac. Reconnecting…");
|
||||
setTimeout(connect, 500 * 2 ** retry);
|
||||
};
|
||||
}
|
||||
@@ -269,7 +269,7 @@ function control(m) {
|
||||
document.title = tag ? `${name} [${tag}]` : name;
|
||||
if (!m.input) {
|
||||
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
|
||||
show(m.inputMessage || "Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
|
||||
show("Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
|
||||
}
|
||||
} else if (m.t === "error") {
|
||||
stats.errors.push(m.message);
|
||||
@@ -391,7 +391,7 @@ addEventListener("keydown", e => onKey(e, true));
|
||||
addEventListener("keyup", e => onKey(e, false));
|
||||
addEventListener("blur", () => send({ t: "release" }));
|
||||
|
||||
show("Connecting to your computer…");
|
||||
show("Connecting to the Mac…");
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+302
-65
@@ -44,6 +44,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
import frame_android_import # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
from apk_sources import fdroid as apk_fdroid # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
@@ -55,7 +56,6 @@ import frame_contact # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
@@ -117,6 +117,12 @@ def route(alias, host_opts):
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
# Invalidate launches before publishing the new route. send/stop share
|
||||
# a writer lock, so an old process cannot receive events after this.
|
||||
for name in ("_input", "_touch"):
|
||||
agent = globals().get(name)
|
||||
if agent:
|
||||
agent.stop()
|
||||
FRAME = frame_android.FRAME = alias
|
||||
HOST_OPTS = list(host_opts)
|
||||
MUX[:] = [*MUX_BASE, *HOST_OPTS]
|
||||
@@ -126,9 +132,6 @@ def route(alias, host_opts):
|
||||
mv = globals().get("macview")
|
||||
if mv:
|
||||
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
|
||||
agent = globals().get("_input")
|
||||
if moved and agent:
|
||||
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
|
||||
|
||||
|
||||
LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
@@ -162,13 +165,42 @@ def busy_thread(fn, *args):
|
||||
with _work_lock:
|
||||
_work[0] += 1
|
||||
|
||||
released = False
|
||||
|
||||
def release():
|
||||
nonlocal released
|
||||
with _work_lock:
|
||||
if not released:
|
||||
released = True
|
||||
_work[0] -= 1
|
||||
|
||||
def run():
|
||||
try:
|
||||
fn(*args)
|
||||
finally:
|
||||
with _work_lock:
|
||||
_work[0] -= 1
|
||||
return threading.Thread(target=run, daemon=True)
|
||||
release()
|
||||
try:
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
except Exception:
|
||||
release()
|
||||
raise
|
||||
start = worker.start
|
||||
|
||||
def start_counted():
|
||||
try:
|
||||
start()
|
||||
except Exception:
|
||||
release()
|
||||
raise
|
||||
worker.start = start_counted
|
||||
return worker
|
||||
|
||||
|
||||
def agent_target():
|
||||
"""Stable identity and reviewable name for a human-approved agent action."""
|
||||
device = LINK.active_device() if LINK else {"id": "local" if LOCAL else FRAME,
|
||||
"name": "This headset" if LOCAL else FRAME}
|
||||
return {"id": device["id"], "name": device["name"]}
|
||||
|
||||
APPID = re.compile(r"^\d{1,10}$")
|
||||
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||
@@ -257,18 +289,13 @@ _backfill = {"running": False, "last": 0.0}
|
||||
_backfill_lock = threading.Lock()
|
||||
|
||||
|
||||
def backfill_art(apps=(), titles=()):
|
||||
def backfill_art(apps=(), titles=(), meant=None):
|
||||
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
|
||||
|
||||
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
|
||||
names the user may have customised. Runs in the background, at most once every five minutes."""
|
||||
pkgs = [a["package"] for a in apps if a.get("art_pending")]
|
||||
gids = [t["id"] for t in titles if t.get("art_pending")]
|
||||
with _backfill_lock:
|
||||
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||
return False
|
||||
_backfill.update(running=True, last=time.time())
|
||||
|
||||
def run():
|
||||
try:
|
||||
frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing
|
||||
@@ -283,7 +310,20 @@ def backfill_art(apps=(), titles=()):
|
||||
finally:
|
||||
with _backfill_lock:
|
||||
_backfill["running"] = False
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
# Listing callers hold working() until this counted worker is registered.
|
||||
# Other callers may supply the identity captured with their listing.
|
||||
with working(meant):
|
||||
with _backfill_lock:
|
||||
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||
return False
|
||||
previous = dict(_backfill)
|
||||
_backfill.update(running=True, last=time.time())
|
||||
try:
|
||||
busy_thread(run).start()
|
||||
except Exception:
|
||||
with _backfill_lock:
|
||||
_backfill.update(previous)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
@@ -416,22 +456,26 @@ def ensure_master():
|
||||
LINK.ensure()
|
||||
|
||||
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
route_gen = LINK.gen if LINK else None # which headset this command is for
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True, route=None):
|
||||
# Long-lived helper delivery/cleanup owns an immutable command prefix. It
|
||||
# must not reconnect through the currently selected headset after a switch.
|
||||
route_gen = LINK.gen if LINK and route is None else None
|
||||
alias = route[-1] if route is not None else FRAME
|
||||
try:
|
||||
ensure_master()
|
||||
if route is None:
|
||||
ensure_master()
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
r = frame_host.run_ssh([*(route if route is not None else [*SSH, FRAME]), remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
|
||||
raise frame_host.link_failure(Failure(f"Timed out talking to {alias}"))
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
if r.returncode == 255 and repair_ssh_config(err):
|
||||
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
|
||||
if r.returncode == 255 and LINK and unreachable(err):
|
||||
return ssh(remote, stdin=stdin, timeout=timeout, text=text, route=route)
|
||||
if r.returncode == 255 and LINK and route is None and unreachable(err):
|
||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||
@@ -965,6 +1009,18 @@ class InputAgent:
|
||||
self.packages = kdeconnect_packages() if packages is None else packages
|
||||
# generation counts stop()s; launching is the generation a launch is under way for.
|
||||
self.status, self.launching, self.generation = {"state": "off"}, None, 0
|
||||
# Each launch thread retains its owner through delivery, retry and late
|
||||
# cleanup, even when a new generation starts concurrently.
|
||||
self._launch_local = threading.local()
|
||||
|
||||
def delivery_ssh(self, command, **kwargs):
|
||||
context = getattr(self._launch_local, "context", None)
|
||||
if context:
|
||||
with self.lock:
|
||||
if self.generation != context[0]:
|
||||
raise Failure("stopped")
|
||||
return ssh(command, route=context[1], **kwargs)
|
||||
return ssh(command, **kwargs)
|
||||
|
||||
def command(self, folder=""):
|
||||
code = base64.b64encode(self.source.read_bytes()).decode()
|
||||
@@ -987,14 +1043,14 @@ class InputAgent:
|
||||
return "" # nothing to copy (the agent says so if it needed them)
|
||||
if not force:
|
||||
# Bytes, so Windows doesn't turn the stamp's line ends into CRLF.
|
||||
have = ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
|
||||
have = self.delivery_ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
|
||||
" && echo yes || true", stdin=kdeconnect_stamp(self.packages).encode(), text=False, timeout=20)
|
||||
if have.strip() == b"yes":
|
||||
return ""
|
||||
# A folder of its own: a cancelled start's agent may still be cleaning up another.
|
||||
folder = f"{KDECONNECT_HOME}/incoming/{secrets.token_hex(8)}"
|
||||
ssh(f"mkdir -p {folder}", timeout=20)
|
||||
try:
|
||||
self.delivery_ssh(f"mkdir -p {folder}", timeout=20)
|
||||
for name, sha in self.packages:
|
||||
path = KDECONNECT / "packages" / name
|
||||
if not path.is_file() or file_sha256(path) != sha:
|
||||
@@ -1002,7 +1058,7 @@ class InputAgent:
|
||||
" (a build runs app/build/fetch-deps.js to add it)", 500)
|
||||
report(f"Copying KDE Connect to the Frame ({name.rsplit('-', 3)[0]})")
|
||||
quoted = shlex.quote(name)
|
||||
ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
|
||||
self.delivery_ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
|
||||
stdin=path.read_bytes(), text=False, timeout=600)
|
||||
except (Failure, OSError):
|
||||
self.discard(folder) # a partial copy is no use to anyone
|
||||
@@ -1014,18 +1070,35 @@ class InputAgent:
|
||||
folder = folder.removeprefix("~/")
|
||||
if folder.startswith(f"{KDECONNECT_HOME}/incoming/") and not LOCAL:
|
||||
try:
|
||||
ssh(f"rm -rf {folder}", timeout=20)
|
||||
context = getattr(self._launch_local, "context", None)
|
||||
ssh(f"rm -rf {folder}", timeout=20, **({"route": context[1]} if context else {}))
|
||||
except (Failure, OSError):
|
||||
pass # never let tidying up get in the way of reporting and retrying
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
with _route_lock, self.lock:
|
||||
if self.launching == self.generation or (self.proc and self.proc.poll() is None):
|
||||
return
|
||||
# (A launch from before a stop() may still be finishing; it ends itself.)
|
||||
self.launching, self.status = self.generation, {"state": "starting"}
|
||||
generation = self.generation
|
||||
threading.Thread(target=self._launch, args=(generation,), daemon=True).start()
|
||||
route = tuple([*SSH, FRAME])
|
||||
|
||||
def run():
|
||||
self._launch_local.context = (generation, route)
|
||||
try:
|
||||
self._launch(generation)
|
||||
finally:
|
||||
del self._launch_local.context
|
||||
try:
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
except Exception:
|
||||
with self.lock:
|
||||
if self.launching == generation:
|
||||
self.launching = None
|
||||
if self.generation == generation:
|
||||
self.status = {"state": "error", "message": "Couldn't start the input worker"}
|
||||
raise
|
||||
|
||||
def _launch(self, generation, force=False):
|
||||
try:
|
||||
@@ -1038,22 +1111,43 @@ class InputAgent:
|
||||
self.status = {"state": "error", "message": f"Couldn't start the keyboard and trackpad: {e}"}
|
||||
|
||||
def _launch_once(self, generation, force):
|
||||
previous = getattr(self._launch_local, "context", None)
|
||||
if previous is None: # direct synchronous callers, including offline probes
|
||||
with _route_lock, self.lock:
|
||||
self._launch_local.context = (generation, tuple([*SSH, FRAME]))
|
||||
try:
|
||||
self._launch_routed(generation, force, self._launch_local.context[1])
|
||||
finally:
|
||||
if previous is None:
|
||||
del self._launch_local.context
|
||||
|
||||
def _launch_routed(self, generation, force, route):
|
||||
def report(message):
|
||||
with self.lock:
|
||||
if self.generation == generation:
|
||||
self.status = {"state": "installing", "message": message}
|
||||
folder = ""
|
||||
errors = None
|
||||
try:
|
||||
errors = tempfile.TemporaryFile()
|
||||
with self.lock:
|
||||
if self.generation != generation:
|
||||
raise Failure("stopped")
|
||||
ensure_master()
|
||||
with self.lock:
|
||||
if self.generation != generation:
|
||||
raise Failure("stopped")
|
||||
folder = self.deliver(report, force)
|
||||
with self.lock:
|
||||
stopped = self.generation != generation
|
||||
command = [*route, self.command(folder)] if not stopped else None
|
||||
if stopped: # turned off while copying
|
||||
raise Failure("stopped")
|
||||
proc = subprocess.Popen([*SSH, FRAME, self.command(folder)], stdin=subprocess.PIPE,
|
||||
proc = subprocess.Popen(command, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=errors)
|
||||
except (Failure, OSError) as e:
|
||||
if errors is not None:
|
||||
errors.close()
|
||||
self.discard(folder) # no agent will take the copy over
|
||||
message = str(e)
|
||||
friendly = unreachable(message)
|
||||
@@ -1072,8 +1166,11 @@ class InputAgent:
|
||||
if not stale:
|
||||
self.proc = proc
|
||||
if stale: # turned off meanwhile
|
||||
proc.terminate()
|
||||
wanted, heard = self._watch(proc, errors, retry=not force)
|
||||
self.close(proc)
|
||||
try:
|
||||
wanted, heard = self._watch(proc, errors, retry=not force)
|
||||
finally:
|
||||
errors.close()
|
||||
if not heard:
|
||||
# The agent never started (or was stopped first), so it can't tidy the copy up.
|
||||
self.discard(folder)
|
||||
@@ -1127,29 +1224,35 @@ class InputAgent:
|
||||
Returns the state, with "sent" saying whether the events went; if not,
|
||||
the page keeps them and sends them again once the state is "ready".
|
||||
"""
|
||||
with self.lock:
|
||||
proc, ready = self.proc, self.status.get("state") == "ready"
|
||||
sent = False
|
||||
if not (proc and proc.poll() is None):
|
||||
self.start()
|
||||
elif ready and events:
|
||||
try:
|
||||
# One writer at a time: two devices sending at once mustn't tear a line.
|
||||
with self.write_lock:
|
||||
with self.write_lock:
|
||||
with self.lock:
|
||||
proc, ready = self.proc, self.status.get("state") == "ready"
|
||||
sent = False
|
||||
start = not (proc and proc.poll() is None)
|
||||
if not start and ready and events:
|
||||
try:
|
||||
proc.stdin.write((json.dumps(events) + "\n").encode())
|
||||
proc.stdin.flush()
|
||||
sent = True
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass # _watch reports how it ended
|
||||
sent = True
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
pass # _watch reports how it ended
|
||||
# start() captures the route under _route_lock. Never acquire that lock
|
||||
# under write_lock: route() takes them in the opposite order to stop us.
|
||||
if start:
|
||||
self.start()
|
||||
with self.lock:
|
||||
return {**self.status, "sent": sent}
|
||||
|
||||
def stop(self):
|
||||
with self.lock:
|
||||
proc, self.proc, self.status = self.proc, None, {"state": "off"}
|
||||
self.generation += 1
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
with self.write_lock:
|
||||
with self.lock:
|
||||
proc, self.proc, self.status = self.proc, None, {"state": "off"}
|
||||
self.generation += 1
|
||||
if proc and proc.poll() is None:
|
||||
self.close(proc)
|
||||
|
||||
def close(self, proc):
|
||||
proc.terminate()
|
||||
|
||||
|
||||
_input = InputAgent()
|
||||
@@ -1266,6 +1369,14 @@ class TouchAgent(InputAgent):
|
||||
def __init__(self):
|
||||
super().__init__(source=HERE / "frame_touch.py", packages=[])
|
||||
|
||||
def close(self, proc):
|
||||
# EOF lets the helper release held keys/buttons in finally, including
|
||||
# when a start was invalidated while Popen was still returning.
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
proc.terminate()
|
||||
|
||||
def deliver(self, report, force=False):
|
||||
return ""
|
||||
|
||||
@@ -1598,6 +1709,93 @@ def apk_installed(info, meta, error, seconds):
|
||||
frame_android.install_hooks.append(apk_installed)
|
||||
|
||||
|
||||
# ---- Local Android backups (one APK and its OBB game data) ---------------
|
||||
|
||||
_android_backups = {}
|
||||
_android_backups_lock = threading.Lock()
|
||||
ANDROID_BACKUP_TMP = 'frame-android-backup-'
|
||||
|
||||
|
||||
def _discard_android_backup(entry):
|
||||
for folder in entry['dirs']:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
|
||||
|
||||
def _android_backup(token=None):
|
||||
now = time.time()
|
||||
with _android_backups_lock:
|
||||
stale = [_android_backups.pop(t) for t, e in list(_android_backups.items())
|
||||
if now - e['time'] > 3600]
|
||||
entry = _android_backups.get(token)
|
||||
for old in stale:
|
||||
_discard_android_backup(old)
|
||||
if not entry:
|
||||
raise Failure('This import expired. Choose the backup files again.', 400)
|
||||
device = LINK.active_device()['id'] if LINK else None
|
||||
if entry['device'] != device:
|
||||
raise Failure('The headset changed. Choose the backup files again for this Frame.', 409)
|
||||
entry['time'] = now
|
||||
return entry
|
||||
|
||||
|
||||
def android_backup(body):
|
||||
action = body.get('action')
|
||||
if action == 'begin' or (action == 'inspect' and 'paths' in body):
|
||||
paths = body.get('paths', [])
|
||||
if not isinstance(paths, list) or len(paths) > 100 or any(
|
||||
not isinstance(p, str) or not os.path.isabs(p) for p in paths):
|
||||
raise Failure('Choose local Android backup files.', 400)
|
||||
# A dropped PC-game folder continues through the existing title importer.
|
||||
if body.get('probe') and len(paths) == 1 and Path(paths[0]).is_dir():
|
||||
if not any(p.suffix.lower() in ('.apk', '.obb') for p in Path(paths[0]).iterdir()):
|
||||
return {'android': False}
|
||||
token = secrets.token_hex(12)
|
||||
entry = {'paths': paths, 'dirs': [], 'plan': None, 'time': time.time(),
|
||||
'device': LINK.active_device()['id'] if LINK else None}
|
||||
with _android_backups_lock:
|
||||
_android_backups[token] = entry
|
||||
_android_backup(token) # also expire abandoned imports
|
||||
if action == 'begin':
|
||||
return {'token': token}
|
||||
else:
|
||||
token = str(body.get('token') or '')
|
||||
entry = _android_backup(token)
|
||||
if action == 'inspect':
|
||||
try:
|
||||
entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs'])
|
||||
except (frame_android.FrameError, OSError) as error:
|
||||
with _android_backups_lock:
|
||||
_android_backups.pop(token, None)
|
||||
_discard_android_backup(entry)
|
||||
raise Failure(str(error), 400, getattr(error, 'apk', None))
|
||||
return {'android': True, 'token': token, 'plan': frame_android_import.public(entry['plan'])}
|
||||
if action not in ('install', 'discard'):
|
||||
raise Failure('Unknown Android backup action.', 400)
|
||||
if action == 'install' and not entry['plan']:
|
||||
raise Failure('Read the backup before importing it.', 400)
|
||||
display = body.get('display', 'auto')
|
||||
if display not in ('auto', 'flat', 'vr'):
|
||||
raise Failure('Invalid Android display mode.', 400)
|
||||
with _android_backups_lock:
|
||||
if _android_backups.pop(token, None) is not entry:
|
||||
raise Failure('This import was already started or cancelled. Choose the files again.', 409)
|
||||
if action == 'discard':
|
||||
_discard_android_backup(entry)
|
||||
return {'message': 'Import cancelled'}
|
||||
|
||||
def work(report):
|
||||
try:
|
||||
return frame_android_import.install(entry['plan'], display, report)
|
||||
finally:
|
||||
_discard_android_backup(entry)
|
||||
try:
|
||||
ensure_master()
|
||||
return start_job('Import ' + entry['plan']['checked']['label'], work, progress=True)
|
||||
except BaseException:
|
||||
_discard_android_backup(entry)
|
||||
raise
|
||||
|
||||
|
||||
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
|
||||
#
|
||||
# Installing is two steps: inspect (a dropped file is uploaded and a zip
|
||||
@@ -2213,7 +2411,7 @@ def _pid_alive(pid):
|
||||
def _tmp_places():
|
||||
"""Where each kind of staging folder goes, and its name before the server's PID."""
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, ANDROID_BACKUP_TMP), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
|
||||
|
||||
|
||||
@@ -2344,9 +2542,8 @@ def push_media(path):
|
||||
|
||||
# The tunnel gets its own connection: the shared master's options would win
|
||||
# over anything added after them.
|
||||
macview = frame_pcview.host_view(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
|
||||
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin.encode("utf-8") if stdin is not None else None,
|
||||
timeout=timeout, text=False).decode("utf-8", errors="replace"),
|
||||
macview = frame_macview.MacView(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
|
||||
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin, timeout=timeout),
|
||||
FRAME, track=_live_tunnels.add)
|
||||
|
||||
|
||||
@@ -2431,11 +2628,14 @@ def source_search(query):
|
||||
q = args.get('q', [''])[0]
|
||||
vr = args.get('vr', [''])[0]
|
||||
installable = args.get('installable', [''])[0]
|
||||
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
|
||||
category, phone = args.get('category', [''])[0], args.get('phone', [''])[0]
|
||||
if (len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0')
|
||||
or phone not in ('', 'true', 'false', '1', '0') or len(category) > 40):
|
||||
raise Failure('Invalid search filters', 400)
|
||||
try:
|
||||
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
|
||||
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
|
||||
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
|
||||
category=category or None, phone=phone in ('true', '1'))
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
@@ -2501,6 +2701,7 @@ def source_manage(body):
|
||||
|
||||
|
||||
POST = {
|
||||
"/api/android/backup": android_backup,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -2679,6 +2880,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/store":
|
||||
# The Android store as a headset panel (scripts/store-on-frame.sh).
|
||||
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/settings/artwork":
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
@@ -2725,18 +2929,25 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/apk-versions":
|
||||
self.send_json(apk_versions(url.query))
|
||||
elif path == "/api/android":
|
||||
ensure_master()
|
||||
apps = frame_android.list_apps()
|
||||
backfill_art(apps=apps)
|
||||
self.send_json({"apps": apps})
|
||||
with working():
|
||||
ensure_master()
|
||||
apps = frame_android.list_apps()
|
||||
backfill_art(apps=apps)
|
||||
try: # store artwork for the library's cards, from saved indexes only
|
||||
art = apk_search.artwork_for(apps)
|
||||
except Exception:
|
||||
art = {}
|
||||
self.send_json({"apps": [dict(a, store_art=art[a['package']]) if a.get('package') in art else a
|
||||
for a in apps]})
|
||||
elif path == "/api/quest":
|
||||
self.send_json(quest_status(url.query))
|
||||
elif path == "/api/meta":
|
||||
self.send_json(meta_status(url.query))
|
||||
elif path == "/api/titles":
|
||||
ensure_master()
|
||||
titles_list = frame_titles.list_titles()
|
||||
backfill_art(titles=titles_list)
|
||||
with working():
|
||||
ensure_master()
|
||||
titles_list = frame_titles.list_titles()
|
||||
backfill_art(titles=titles_list)
|
||||
self.send_json({"titles": titles_list})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
@@ -2744,10 +2955,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"notices": licenses()})
|
||||
elif path == "/api/panels":
|
||||
self.send_json(panels())
|
||||
elif path == "/api/touch":
|
||||
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||
elif path == "/api/input":
|
||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
||||
elif path in ("/api/touch", "/api/input"):
|
||||
agent = _touch if path == "/api/touch" else _input
|
||||
if parse_qs(url.query).get("start") == ["1"]:
|
||||
# Validate and reserve before start() captures its immutable
|
||||
# route and schedules a worker. Status-only polls stay free.
|
||||
meant = self.headers.get("X-Frame-Device")
|
||||
if LINK and not meant:
|
||||
raise Failure("Control start needs the intended X-Frame-Device headset", 400)
|
||||
with working(meant):
|
||||
result = agent.send([])
|
||||
else:
|
||||
result = dict(agent.status)
|
||||
self.send_json(result)
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/queue":
|
||||
@@ -2939,7 +3159,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("empty or too-large upload", 400)
|
||||
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
|
||||
raise Failure("APK install needs a .apk file", 400)
|
||||
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
|
||||
backup = None
|
||||
if mode == 'android-backup':
|
||||
backup = _android_backup(self.headers.get('X-Android-Backup'))
|
||||
if (not name.lower().endswith(('.apk', '.obb')) or '/' in name or '\\' in name
|
||||
or len(backup['paths']) >= 100 or any(Path(p).name == name for p in backup['paths'])):
|
||||
raise Failure('Choose one APK and its game data, with no duplicate filenames.', 400)
|
||||
prefix = f'{ANDROID_BACKUP_TMP}{os.getpid()}-' if backup is not None else 'frame-ui-'
|
||||
tmp = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
keep = False
|
||||
try:
|
||||
dest = tmp / name
|
||||
@@ -2951,6 +3178,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("upload interrupted", 400)
|
||||
f.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
if backup is not None:
|
||||
with _android_backups_lock:
|
||||
if _android_backups.get(self.headers.get('X-Android-Backup')) is not backup:
|
||||
raise Failure('This import was cancelled. Choose the backup files again.', 400)
|
||||
backup['paths'].append(str(dest))
|
||||
backup['dirs'].append(str(tmp))
|
||||
backup['plan'] = None
|
||||
backup['time'] = time.time()
|
||||
keep = True
|
||||
return {'message': f'Read {name}', 'token': self.headers.get('X-Android-Backup')}
|
||||
if mode == "media":
|
||||
return push_media(dest)
|
||||
if mode == "apkinfo":
|
||||
|
||||
+558
@@ -0,0 +1,558 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APK Store</title>
|
||||
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
|
||||
"The store in the headset"). Served by ui/server.py running on the Frame
|
||||
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
|
||||
sources, categories and artwork as the Store in Frame Control (/api/search,
|
||||
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
|
||||
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
|
||||
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
|
||||
keyboard for search. -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
|
||||
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
|
||||
--good: #8ed36b; --bad: #ef7a64;
|
||||
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||||
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
|
||||
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden; user-select: none; -webkit-user-select: none; }
|
||||
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
|
||||
min-height: 52px; padding: 0 20px; cursor: pointer; }
|
||||
button:hover { background: #ffffff1c; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
:focus-visible { outline: 3px solid var(--link); outline-offset: 3px; }
|
||||
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
|
||||
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
|
||||
button.danger { color: var(--bad); }
|
||||
svg { width: 22px; height: 22px; flex: none; }
|
||||
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
|
||||
/* Rail */
|
||||
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
|
||||
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
|
||||
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
|
||||
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
|
||||
.rail button:hover { background: #ffffff10; color: var(--bright); }
|
||||
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
|
||||
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
|
||||
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
|
||||
.rail .n:empty { display: none; }
|
||||
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
|
||||
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
|
||||
/* Main */
|
||||
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
|
||||
.bar { display: flex; gap: 12px; align-items: center; }
|
||||
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
|
||||
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
|
||||
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
|
||||
.search input::placeholder { color: var(--dim); }
|
||||
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
|
||||
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
|
||||
.heading span { color: var(--dim); font-size: 16px; }
|
||||
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
|
||||
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.installed-status { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px;
|
||||
border: 1px solid var(--line); border-radius: 12px; background: #ffffff0a; font-size: 16px; }
|
||||
.installed-status button { flex: none; }
|
||||
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
|
||||
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
|
||||
background: var(--card); border: 1px solid var(--line); }
|
||||
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
|
||||
.card .art { flex: 0 0 52%; }
|
||||
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
|
||||
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
|
||||
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
|
||||
.art { position: relative; display: block; overflow: hidden; width: 100%;
|
||||
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
|
||||
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
|
||||
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
|
||||
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
|
||||
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
|
||||
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
|
||||
.art.shots .initial, .art.iconart .initial { display: none; }
|
||||
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
|
||||
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
|
||||
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
|
||||
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
|
||||
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
|
||||
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
|
||||
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
|
||||
/* Home */
|
||||
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
|
||||
.hero .art { position: absolute; inset: 0; height: 100%; }
|
||||
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
|
||||
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
|
||||
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
|
||||
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
|
||||
/* Pager */
|
||||
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.pager button { min-width: 180px; }
|
||||
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
|
||||
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
|
||||
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
|
||||
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
|
||||
@keyframes shimmer { to { background-position: -150% 0; } }
|
||||
/* Detail sheet and keyboard sit over everything. */
|
||||
.overlay { position: fixed; inset: 0; background: transparent; color: inherit; display: grid; place-items: center; z-index: 10;
|
||||
margin: 0; border: 0; padding: 0; width: 100%; height: 100%; max-width: none; max-height: none; }
|
||||
.overlay::backdrop { background: #000b; }
|
||||
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
|
||||
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
|
||||
.sheet > .art { flex: 0 0 250px; }
|
||||
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
|
||||
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
|
||||
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
|
||||
.sheet .top { display: flex; gap: 18px; align-items: center; }
|
||||
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
|
||||
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
|
||||
.sheet .meta { color: var(--dim); font-size: 16px; }
|
||||
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
|
||||
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
|
||||
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
|
||||
.sheet .acts .close { margin-left: auto; }
|
||||
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
|
||||
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
|
||||
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
|
||||
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
|
||||
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
|
||||
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
|
||||
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
|
||||
.kbd .row { display: flex; gap: 8px; justify-content: center; }
|
||||
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
|
||||
.kbd .wide { min-width: 320px; }
|
||||
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
|
||||
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
|
||||
.toast.bad { background: #3a1416; border-color: var(--bad); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="rail" role="navigation" aria-label="Store">
|
||||
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
|
||||
<div id="cats"></div>
|
||||
<div class="sep"></div>
|
||||
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
|
||||
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
|
||||
</div>
|
||||
<main>
|
||||
<div class="bar">
|
||||
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
|
||||
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
|
||||
</div>
|
||||
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
|
||||
<div class="content" id="content"></div>
|
||||
<div class="pager">
|
||||
<button id="prev">◀ Previous</button>
|
||||
<span class="dots" id="page"></span>
|
||||
<button id="next">Next ▶</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog class="overlay" id="detail" aria-labelledby="detailTitle" hidden><div class="sheet" id="sheet"></div></dialog>
|
||||
<div class="kbd" id="kbd" hidden></div>
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script>
|
||||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const PER_PAGE = 8;
|
||||
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
|
||||
const CATS = [
|
||||
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
|
||||
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
|
||||
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
|
||||
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
|
||||
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
|
||||
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
|
||||
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
|
||||
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
|
||||
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
|
||||
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
|
||||
];
|
||||
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
|
||||
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
|
||||
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
|
||||
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
|
||||
// needsData: installed packages still waiting for their game data (any view, while installed)
|
||||
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
|
||||
needsData: new Set(), installedState: "unknown", installedError: "", installedRequest: 0,
|
||||
request: 0, loading: false, retry: 0 };
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
|
||||
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
|
||||
const r = await fetch(path, opts);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
||||
return j;
|
||||
}
|
||||
let toastTimer;
|
||||
function toast(msg, bad = false) {
|
||||
const t = $("toast");
|
||||
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
|
||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
|
||||
}
|
||||
|
||||
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
|
||||
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
|
||||
const initial = name => [...String(name || "?")][0].toUpperCase();
|
||||
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
|
||||
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
|
||||
function art(name, a = {}, extra = "") {
|
||||
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
|
||||
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
|
||||
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
|
||||
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
|
||||
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
|
||||
return `<span class="art" ${base}>${ini}${extra}</span>`;
|
||||
}
|
||||
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
|
||||
document.addEventListener("error", e => {
|
||||
const el = e.target;
|
||||
if (!el.matches?.(".art img, .icon img")) return;
|
||||
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
|
||||
const box = el.closest(".shots, .iconart"); el.remove();
|
||||
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
|
||||
}, true);
|
||||
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
|
||||
|
||||
// ---- Data ----
|
||||
async function loadInstalled() {
|
||||
const request = ++st.installedRequest;
|
||||
st.installedState = "loading"; st.installedError = "";
|
||||
$("instN").textContent = "?";
|
||||
render(); if (typeof drawDetail === "function") drawDetail();
|
||||
try {
|
||||
const result = await api("/api/android");
|
||||
if (request !== st.installedRequest) return;
|
||||
if (!Array.isArray(result.apps)) throw new Error("Invalid installed-app response");
|
||||
st.installed = new Map(result.apps.map(a => [a.package, a]));
|
||||
st.installedState = "ready";
|
||||
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
|
||||
} catch (e) {
|
||||
if (request !== st.installedRequest) return;
|
||||
st.installedState = "error"; st.installedError = e.message;
|
||||
}
|
||||
$("instN").textContent = st.installedState === "ready" ? st.installed.size || "" : "?";
|
||||
render(); if (typeof drawDetail === "function") drawDetail();
|
||||
}
|
||||
async function search(quiet) {
|
||||
const request = ++st.request;
|
||||
clearTimeout(st.retry);
|
||||
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
|
||||
st.loading = !quiet; if (!quiet) render();
|
||||
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
|
||||
try {
|
||||
const result = await api("/api/search?" + params);
|
||||
if (request !== st.request) return;
|
||||
st.apps = result.apps; st.loading = false;
|
||||
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
|
||||
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
|
||||
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
|
||||
if (!quiet) st.page = 0;
|
||||
render();
|
||||
} catch (e) {
|
||||
if (request !== st.request) return;
|
||||
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
|
||||
}
|
||||
}
|
||||
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
|
||||
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
|
||||
function installedOf(app) {
|
||||
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
|
||||
return pkg ? st.installed.get(pkg) : undefined;
|
||||
}
|
||||
|
||||
// ---- Pages ----
|
||||
function list() {
|
||||
if (st.view === "installed") {
|
||||
const q = st.q.toLowerCase();
|
||||
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
|
||||
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
|
||||
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
|
||||
}
|
||||
return st.home ? st.home.rest : st.apps;
|
||||
}
|
||||
function card(app, i) {
|
||||
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
|
||||
const state = m ? `<span class="tag">${st.installedState !== "ready" ? "Installed (last read)" : m.running ? "Running" : "Installed"}</span>`
|
||||
: job && !job.done ? `<span class="tag">Installing…</span>`
|
||||
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
|
||||
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
|
||||
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
|
||||
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
|
||||
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
|
||||
}
|
||||
function render(error) {
|
||||
const home = st.view === "" && !st.q;
|
||||
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
|
||||
const content = $("content");
|
||||
const cached = st.installed.size > 0;
|
||||
const installedNotice = st.installedState === "ready" ? "" : `<div class="installed-status" role="status"><span>${
|
||||
st.installedState === "error" ? `Couldn't read installed apps: ${esc(st.installedError)}`
|
||||
: st.installedState === "loading" ? "Reading installed apps…" : "Installed apps haven't been read yet."
|
||||
}${cached ? " Showing last known apps; their status may be out of date." : " Installed status is unknown."}</span>${
|
||||
st.installedState === "loading" ? "" : '<button data-retry-installed>Retry installed apps</button>'
|
||||
}</div>`;
|
||||
if (st.loading) {
|
||||
content.innerHTML = installedNotice + `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
|
||||
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
|
||||
}
|
||||
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
|
||||
st.home = null;
|
||||
if (home && st.apps.length) {
|
||||
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
|
||||
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
|
||||
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
|
||||
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
|
||||
const mixed = [];
|
||||
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
|
||||
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
|
||||
}
|
||||
const items = list();
|
||||
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
|
||||
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
|
||||
st.page = Math.min(st.page, pages - 1);
|
||||
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
|
||||
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
|
||||
$("count").textContent = home || (st.view === "installed" && !cached && st.installedState !== "ready") ? ""
|
||||
: `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}${st.view === "installed" && st.installedState !== "ready" ? " (last read)" : ""}`;
|
||||
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
|
||||
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
|
||||
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
|
||||
: st.view === "installed" && st.installedState !== "ready" && !cached ? `<div class="empty"><h2>${st.installedState === "loading" ? "Reading installed apps…" : "Installed apps unavailable"}</h2>Read the installed list before checking what's on your Frame.</div>`
|
||||
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
|
||||
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
|
||||
let html = installedNotice;
|
||||
if (st.page === 0 && st.home?.featured) {
|
||||
const f = st.home.featured, o = f.offers[0];
|
||||
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
|
||||
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
|
||||
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
|
||||
}
|
||||
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
|
||||
content.innerHTML = html;
|
||||
}
|
||||
function turn(by) {
|
||||
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
|
||||
st.page += by; render();
|
||||
}
|
||||
|
||||
// ---- Detail sheet ----
|
||||
let current = null; // {app, installed}
|
||||
let detailReturn = null, removeConfirmed = false;
|
||||
const detailIdentity = app => app.package || app.installed?.package || offerKey(app);
|
||||
const ver = v => String(v || "").replace(/^v/i, "");
|
||||
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
|
||||
function openDetail(app, opener = document.activeElement) {
|
||||
detailReturn = { node: opener, identity: detailIdentity(app) }; removeConfirmed = false;
|
||||
current = app; drawDetail(); $("detail").hidden = false;
|
||||
$("detail").append($("toast")); // Notifications stay above the native modal's backdrop.
|
||||
if (!$("detail").open) $("detail").showModal();
|
||||
$("detailTitle").focus();
|
||||
const o = app.offers[0];
|
||||
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
|
||||
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
|
||||
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
function drawDetail() {
|
||||
const app = current; if (!app) return;
|
||||
const focused = document.activeElement, hadFocus = $("sheet").contains(focused);
|
||||
const focusedAction = hadFocus && focused.dataset.act;
|
||||
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
|
||||
if (app.installed && !m) return closeDetail(); // removed
|
||||
const shots = (o.artwork?.screenshots || []).filter(valid);
|
||||
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
|
||||
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
|
||||
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
|
||||
const reasons = (o.fit?.reasons || []).filter(Boolean);
|
||||
let acts;
|
||||
const progress = j => {
|
||||
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
|
||||
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
|
||||
};
|
||||
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
|
||||
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
|
||||
if (job && !job.done) {
|
||||
acts = progress(job);
|
||||
} else if (m) {
|
||||
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
|
||||
+ `<button class="danger" data-act="remove"${removeConfirmed ? ' data-sure="1"' : ""}>${removeConfirmed ? "Remove it and its data?" : "Remove"}</button>`;
|
||||
} else if (!o.source) {
|
||||
acts = "";
|
||||
} else if (o.downloadable === false) {
|
||||
acts = `<button disabled>Get it from the developer's page</button>`;
|
||||
} else if (o.verdict?.tone === "blocked") {
|
||||
acts = `<button disabled>Won't run on the Frame</button>`;
|
||||
} else if (st.installedState !== "ready") {
|
||||
acts = `<button disabled>${st.installedState === "loading" ? "Checking installed apps…" : "Read installed apps before installing"}</button>`;
|
||||
} else {
|
||||
acts = `<button class="primary" data-act="install">Install</button>`;
|
||||
}
|
||||
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
|
||||
const failed = data?.error || job?.error;
|
||||
const note = (failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "")
|
||||
+ (st.installedState === "ready" ? "" : `<div class="meta" role="status">${m ? "Showing last known installed status; it may be out of date." : "Installed status is unknown."}${st.installedState === "loading" ? " Reading installed apps…" : ' <button data-retry-installed>Retry installed apps</button>'}</div>`);
|
||||
$("sheet").innerHTML = `${top}<div class="inner">
|
||||
<div class="top">${icon(app.name, o.artwork)}<div><h2 id="detailTitle" tabindex="-1">${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
|
||||
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
|
||||
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
|
||||
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
|
||||
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
|
||||
if (hadFocus) {
|
||||
const replacement = focusedAction ? $("sheet").querySelector(`[data-act="${focusedAction}"]`)
|
||||
: focused.matches("[data-retry-installed]") ? $("sheet").querySelector("[data-retry-installed]") : $("detailTitle");
|
||||
(replacement && !replacement.disabled ? replacement : $("sheet").querySelector('[data-act="close"]')).focus();
|
||||
}
|
||||
}
|
||||
function closeDetail() {
|
||||
if (!current && !$("detail").open) return;
|
||||
const origin = detailReturn;
|
||||
current = null; detailReturn = null; removeConfirmed = false;
|
||||
if ($("detail").open) $("detail").close();
|
||||
$("detail").hidden = true;
|
||||
document.body.append($("toast"));
|
||||
if (origin?.node?.isConnected) return origin.node.focus();
|
||||
// Polling can replace the background cards too. Find the same app in the current page.
|
||||
const items = list();
|
||||
const card = [...document.querySelectorAll(".card[data-i]")].find(b => detailIdentity(items[+b.dataset.i]) === origin?.identity);
|
||||
const feature = st.home?.featured && detailIdentity(st.home.featured) === origin?.identity && document.querySelector("[data-feature]");
|
||||
(card || feature || $("q")).focus();
|
||||
}
|
||||
$("detail").onclose = () => { if (!$("detail").open) closeDetail(); };
|
||||
$("detail").oncancel = e => { e.preventDefault(); closeDetail(); };
|
||||
$("detail").onkeydown = e => {
|
||||
if (e.key !== "Tab") return;
|
||||
const buttons = [...$("sheet").querySelectorAll("button:not(:disabled)")];
|
||||
const first = buttons[0], last = buttons[buttons.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === $("detailTitle"))) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
|
||||
async function runJob(key, start) {
|
||||
const job = { stage: "Downloading", percent: null, done: false };
|
||||
st.jobs.set(key, job); drawDetail(); render();
|
||||
try {
|
||||
const { job: id } = await start();
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
|
||||
if (!j.done) { drawDetail(); continue; }
|
||||
if (j.error) throw new Error(j.error);
|
||||
toast(j.message); job.result = j.result || {};
|
||||
break;
|
||||
}
|
||||
} catch (e) { job.error = e.message; toast(e.message, true); }
|
||||
finally {
|
||||
job.done = true;
|
||||
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
|
||||
await loadInstalled(); render(); drawDetail();
|
||||
}
|
||||
return job;
|
||||
}
|
||||
$("sheet").onclick = async e => {
|
||||
const b = e.target.closest("[data-act]"); if (!b || !current) return;
|
||||
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
|
||||
if (act === "close") return closeDetail();
|
||||
if (act === "install") {
|
||||
toast(`Installing ${app.name}. The first time takes a minute…`);
|
||||
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
|
||||
if (job.result?.package) {
|
||||
st.installedFrom.set(key, job.result.package);
|
||||
if (job.result.game_data) st.needsData.add(job.result.package);
|
||||
}
|
||||
return render(), drawDetail();
|
||||
}
|
||||
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
|
||||
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
|
||||
if (!job.error) st.needsData.delete(m.package);
|
||||
return render(), drawDetail();
|
||||
}
|
||||
const pkg = m?.package; if (!pkg) return;
|
||||
if (act === "remove" && b.dataset.sure !== "1") {
|
||||
removeConfirmed = true;
|
||||
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
|
||||
}
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await api("/api/android", { action: act, package: pkg });
|
||||
toast(r.message);
|
||||
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
|
||||
if (act === "launch") closeDetail();
|
||||
} catch (err) { toast(err.message, true); }
|
||||
await loadInstalled(); render();
|
||||
if (current === app) drawDetail();
|
||||
};
|
||||
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
|
||||
|
||||
// ---- Navigation ----
|
||||
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.closest("[data-retry-installed]")) { loadInstalled(); return; }
|
||||
const v = e.target.closest("[data-view]");
|
||||
if (v && !e.target.closest("#sheet")) {
|
||||
st.view = v.dataset.view; st.q = ""; $("q").value = "";
|
||||
if (st.view === "installed") loadInstalled().then(() => search()); else search();
|
||||
return;
|
||||
}
|
||||
const featured = e.target.closest("[data-feature]");
|
||||
if (featured) return openDetail(st.home.featured, featured);
|
||||
const c = e.target.closest(".card[data-i]");
|
||||
if (c) openDetail(list()[+c.dataset.i], c);
|
||||
});
|
||||
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
|
||||
$("prev").onclick = () => turn(-1);
|
||||
$("next").onclick = () => turn(1);
|
||||
// The thumbstick arrives as a mouse wheel: one notch, one page.
|
||||
let wheelAt = 0;
|
||||
document.querySelector("main").addEventListener("wheel", e => {
|
||||
e.preventDefault();
|
||||
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
|
||||
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
|
||||
}, { passive: false });
|
||||
let qTimer;
|
||||
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
|
||||
|
||||
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
|
||||
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
|
||||
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
|
||||
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
|
||||
$("kbd").onclick = e => {
|
||||
const b = e.target.closest("[data-k]"); if (!b) return;
|
||||
const k = b.dataset.k, q = $("q");
|
||||
if (k === "done") { $("kbd").hidden = true; return; }
|
||||
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
|
||||
q.dispatchEvent(new Event("input"));
|
||||
};
|
||||
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
|
||||
|
||||
loadInstalled().then(() => search());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user