mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 09:01:40 +02:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72be95f25 | ||
|
|
65fedbf01e | ||
|
|
311ee70783 | ||
|
|
f832d0b912 | ||
|
|
361bcf8a1b | ||
|
|
10f86d3e7a | ||
|
|
5fea1526ca | ||
|
|
7a7dca70de | ||
|
|
bf8c500a87 | ||
|
|
71fe208fea | ||
|
|
2b838ede9e | ||
|
|
8402a671ef | ||
|
|
310e778d00 | ||
|
|
a909beb39e | ||
|
|
e2601965bd | ||
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
26206e85b4 | ||
|
|
d7a887b98c | ||
|
|
30cbe1c901 | ||
|
|
f4efefa958 | ||
|
|
a5980d8f98 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
810ef1822d | ||
|
|
3dd8e982a3 | ||
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
a13f4fafab | ||
|
|
ea0b6a80bd | ||
|
|
d546568a11 | ||
|
|
57a9d8c717 | ||
|
|
b351fe7ad6 | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
3cb3defaa7 | ||
|
|
a992f6d896 | ||
|
|
d9d538690e | ||
|
|
391ecbeac5 | ||
|
|
1dc2a99617 | ||
|
|
d6f3d7c380 | ||
|
|
838eaa4e6b | ||
|
|
6104c3f192 | ||
|
|
da5c954ce4 | ||
|
|
2466c5aa14 | ||
|
|
060cf9cd57 | ||
|
|
cd202433bb | ||
|
|
c5fc552c60 | ||
|
|
559caa2dd0 |
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
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PC host libraries
|
||||
# PC in the headset (Experimental). The Windows job builds the native adapter
|
||||
# and runs the real-encoder protocol tests; the installer's own bundle is
|
||||
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
|
||||
# in the app, but shares capture.c and controller.c, so it's still built here.
|
||||
# These builds add the GPL x264 encoder for headless tests only: they're never
|
||||
# uploaded or shipped.
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "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 --with-x264
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
FRAME_PC_REQUIRE_X264: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm 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 && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py --with-x264
|
||||
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
@@ -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
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# Windows PC helper libraries, the macOS helper, 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/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -75,6 +75,28 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# PC in the headset (Experimental, Windows only): our capture adapter and
|
||||
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm 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 && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Windows PC 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
|
||||
@@ -95,6 +117,19 @@ jobs:
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# From the installed app, so this also proves the installer carries the
|
||||
# libraries. After the check above, and without writing __pycache__,
|
||||
# so it can't change what that check compares.
|
||||
- name: Test the installed app's Python with the Windows PC libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
run: |
|
||||
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
|
||||
python="$resources/python/python.exe"
|
||||
native="$(cygpath -w "$resources/desktop/bundle")"
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
|
||||
+10
@@ -7,5 +7,15 @@ 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/
|
||||
@@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -63,17 +63,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
**📁 Files and games**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -89,6 +89,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -218,6 +226,7 @@ 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) | Experimental, Windows only, off until you turn it on: your PC's windows and screens as panels in the Frame. Not yet tried on a real Windows desktop or headset |
|
||||
| [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 |
|
||||
|
||||
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code.
|
||||
it doesn't link to it or include its code. On Windows, Frame Control's own
|
||||
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
|
||||
+10
@@ -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,14 @@ 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() : "");
|
||||
// Text from the headset's clipboard onto this computer's.
|
||||
ipcMain.handle("clipboard:write", (e, text) => {
|
||||
if (!fromUi(e) || typeof text !== "string") return false;
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
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.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.10",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+12
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.10",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -39,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -197,7 +198,16 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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", "pcView"];
|
||||
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 };
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
@@ -11,8 +11,13 @@
|
||||
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"),
|
||||
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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 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" });
|
||||
});
|
||||
|
||||
test("the PC in the headset opt-in is remembered and off unless saved", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.equal(store.read().pcView, undefined);
|
||||
store.write("pcView", "1");
|
||||
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
|
||||
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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.
|
||||
|
||||
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
|
||||
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
|
||||
tests, which have no GPU or desktop to encode with.
|
||||
"""
|
||||
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, with_x264=False):
|
||||
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']
|
||||
if with_x264:
|
||||
names.append('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'),
|
||||
plugins=names, 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')
|
||||
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
|
||||
args = parser.parse_args()
|
||||
build(args.out.resolve(), args.with_x264)
|
||||
@@ -0,0 +1,125 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
|
||||
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
|
||||
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
|
||||
* Swift original, so a change to either must be made to both:
|
||||
* tests/test_pc_controller.py checks this one against the Swift trace. */
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* 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
|
||||
@@ -0,0 +1,147 @@
|
||||
/* 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.
|
||||
@@ -135,88 +135,6 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
|
||||
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
|
||||
Removing a repository clears its state.
|
||||
|
||||
## My itch.io purchases
|
||||
|
||||
The store can also install the Android (Quest) builds of games you **own** on
|
||||
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
|
||||
store, open the sources dialog and use **My itch.io purchases**: create an API
|
||||
key on itch.io (account settings › API keys) and paste it there. Frame Control
|
||||
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
|
||||
is reported and nothing is saved. Once connected, a source called
|
||||
**itch.io (my purchases)** appears, and its games are marked **Owned on
|
||||
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
|
||||
|
||||
How it works, using the documented
|
||||
[server-side API](https://itch.io/docs/api/serverside) with
|
||||
`Authorization: Bearer <key>`:
|
||||
|
||||
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
|
||||
page until an empty page (500 per page, at most 40 pages). Games whose
|
||||
`traits` don't include `p_android` are left out; one entry per game, with the
|
||||
newest key.
|
||||
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
|
||||
for its files. Android uploads are those marked `p_android` (in `traits`, or
|
||||
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
|
||||
first, then full games before demos, newest first. Soundtracks, pre-orders and
|
||||
externally hosted files are skipped.
|
||||
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
|
||||
The answer accepted is a redirect to itch.io's storage host, a JSON body
|
||||
with a `url` field naming it, or the file itself. Frame Control follows the
|
||||
first two without the key, to HTTPS on port 443 only, and only to host names
|
||||
whose every address is public. For IPv4 that means outside every special
|
||||
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
|
||||
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
|
||||
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
|
||||
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
|
||||
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
|
||||
site-local, unique-local and the rest are all refused. It connects only to
|
||||
the addresses it checked, trying each in turn within one 20-second budget
|
||||
for connecting and the TLS handshake, and checks again on each further
|
||||
redirect. A storage URL, first or after any redirect, that contains the API
|
||||
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
|
||||
`api_key` parameter is refused before it is looked up, so the key never
|
||||
reaches the storage server. The APK then takes
|
||||
the normal install path, with the same ABI, OpenXR layer and Meta-services
|
||||
checks and warnings as any other store download.
|
||||
|
||||
The key is stored in `frame_host.data_dir('itch-account.json')`
|
||||
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
|
||||
written through a new 0600 temporary file and moved into place. It is only
|
||||
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
|
||||
Frame Control does not follow api.itch.io's redirects with it. The key, or any
|
||||
part of it from 8 characters, never appears in the page, API responses, errors
|
||||
or logs; the page clears the field as soon as it is sent. A disconnect made
|
||||
while a key is still being checked wins: that key is not saved.
|
||||
Owned-game lists are kept in memory for ten minutes and never written to the
|
||||
disk cache.
|
||||
|
||||
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
|
||||
same key. If that is refused too (or itch.io says "invalid key"), the key is
|
||||
deleted and the store says to connect again; otherwise only that game or
|
||||
download is refused, and the key stays. Rate limits (429) and network errors
|
||||
only fail that source's results, with a readable message. A storage host's
|
||||
refusal is reported as a download error, not as a rate limit.
|
||||
|
||||
### What is verified and what isn't
|
||||
|
||||
Not tested against a live itch.io account at all: everything below was tested
|
||||
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
|
||||
|
||||
| Part | Status |
|
||||
|---|---|
|
||||
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
|
||||
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
|
||||
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
|
||||
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
|
||||
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
|
||||
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
|
||||
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
|
||||
|
||||
Limits: a game whose page doesn't mark Android, or whose Android build is
|
||||
inside a `.zip`, won't install. Android builds have no version codes on
|
||||
itch.io, so only the newest upload can be installed. Owning a game doesn't make
|
||||
it run on the Frame: Quest builds that need Meta's platform services won't.
|
||||
|
||||
## Publish your own repository
|
||||
|
||||
Only publish free APKs you own or have the developer's permission to distribute.
|
||||
|
||||
@@ -73,8 +73,6 @@ does not suppress the other at the aggregator level. Queries filter the current
|
||||
feed window locally: this is not an exhaustive historical itch search. Only
|
||||
explicit zero-price Android entries are returned. Covers are exposed in
|
||||
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
|
||||
Games the user owns on itch.io are a separate, opt-in source that downloads
|
||||
through their own API key: see [My itch.io purchases](apk-repos.md#my-itchio-purchases).
|
||||
Curated GitHub entries include publisher artwork and plain-language summaries.
|
||||
Repository image URLs are pinned to inspected commits. Open Brush screenshots
|
||||
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
|
||||
|
||||
@@ -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,167 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore, and the switch out of a real Desktop Mode session. Unit
|
||||
tests and the fake Frame cover those (see [Verification](#verification)).
|
||||
|
||||
## Back up
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. SteamOS picks the session through
|
||||
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
|
||||
default `/etc/sddm.conf.d/steamos.conf`), and switches with
|
||||
`steamos-session-select` (`holo-session-select` on newer images). This is what
|
||||
Valve's own devkit tools on the Frame read and run
|
||||
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
|
||||
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
|
||||
|
||||
**Check** reads that file and whether gamescope and Plasma are running; it
|
||||
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
|
||||
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
|
||||
running without gamescope (Desktop Mode) does **Return to Game Mode** run
|
||||
exactly `steamos-session-select gamescope` (or `holo-session-select
|
||||
gamescope`) over SSH, after asking. It closes the desktop and anything open in
|
||||
it. Frame Control never edits the SDDM files itself, and runs nothing when the
|
||||
headset is in Game Mode, when it can't tell which mode it's in, or when the
|
||||
image has neither command.
|
||||
|
||||
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
|
||||
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
|
||||
20260925.6191901), and whether `steamos-session-select` there works without
|
||||
the Developer Mode password. If the switch is refused, Frame Control says so and
|
||||
leaves the headset as it was.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame steamos-session-select gamescope`.
|
||||
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
|
||||
button on top of Power. Choose `Current`, or `Previous` to start the other
|
||||
SteamOS slot; both keep your data. Details and the further options
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Battery charge limit (80%): research
|
||||
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
|
||||
charge-limit setting uses on the Deck) is not configured for the Frame. The
|
||||
only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
|
||||
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
|
||||
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
SteamOS 3.7.8 (beta client 2025-05-08)
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
|
||||
- Steam calls SteamOS Manager on the session bus:
|
||||
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
|
||||
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
|
||||
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
|
||||
`steamosctl get-max-charge-level` / `set-max-charge-level` /
|
||||
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
|
||||
and `steamos-manager/src/bin/steamosctl.rs` in
|
||||
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
|
||||
read at commit `83981ab` (2026-10-21).
|
||||
- The root daemon writes the value to sysfs only if the device's TOML in
|
||||
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
|
||||
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
|
||||
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
|
||||
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
|
||||
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
|
||||
`charge_control_end_threshold` on the ACPI battery
|
||||
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
|
||||
On ARM there's no ACPI, so this path can't match there either.
|
||||
- With no `[battery_charge_limit]` section, the interface fails with
|
||||
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
|
||||
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
|
||||
|
||||
## What the Frame has
|
||||
|
||||
- **Upstream `data/devices/steam-frame.toml`** (matched by
|
||||
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
|
||||
and performance profiles only. It has **no `[battery_charge_limit]`**, so
|
||||
SteamOS Manager on the Frame has no charge-limit backend. Its git history
|
||||
(2026-09-10 to 2026-10-12) never added one.
|
||||
- **Battery driver:** the Frame's battery is
|
||||
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
|
||||
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
|
||||
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
|
||||
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
|
||||
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
|
||||
exposes only read-only properties (capacity, voltage, current, temp,
|
||||
charge_full…), with no `charge_control_end_threshold`. Valve's build is
|
||||
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
|
||||
so, a charge stop would have to come from the separate charger IC, not this
|
||||
node.
|
||||
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
|
||||
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
|
||||
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
|
||||
Steam's limit on those by patching steamos-manager
|
||||
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
|
||||
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
|
||||
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
|
||||
read-only checks below answer this.)
|
||||
- **What third parties found:**
|
||||
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
|
||||
the Frame has no `charge_control_end_threshold` and falls back to a vendor
|
||||
`charging_pause` attribute. It also reports that `constant_charge_current`
|
||||
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
|
||||
(`deckard-charger.service`). By its own README, that tool has only run its
|
||||
read-only `status` on a Frame and has **not** been tested against real
|
||||
charging. Two questions are still open: whether pausing stops the current,
|
||||
and whether Valve's daemon undoes it.
|
||||
|
||||
## Why not build it now
|
||||
|
||||
- **No supported endpoint.** The only Valve-supported interface,
|
||||
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
|
||||
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
|
||||
the user's password).
|
||||
- **A rival owner.** Valve's charge daemon already adjusts charging
|
||||
parameters. Toggling `charging_pause` underneath it could be undone without
|
||||
warning, fight it in a loop, or leave charging paused.
|
||||
- **Stuck at a low charge.** If the app or the Frame dies while charging is
|
||||
paused, the Frame could sit at a low charge on the charger. Whether a
|
||||
reboot clears `charging_pause` is unknown.
|
||||
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
|
||||
reflect our limit.
|
||||
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
|
||||
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
|
||||
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
|
||||
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
|
||||
|
||||
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
|
||||
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
|
||||
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
|
||||
call:
|
||||
|
||||
- Read: `steamosctl get-max-charge-level`
|
||||
- Set: `steamosctl set-max-charge-level 80`
|
||||
- Undo: `steamosctl set-max-charge-level -- -1`
|
||||
|
||||
That runs as `steamos` with no sudo. It's the same value Steam's own
|
||||
Settings → Power shows, so both stay in sync.
|
||||
|
||||
## Headset checks (read-only first)
|
||||
|
||||
Run these over SSH as `steamos`. Steps 1–4 only read.
|
||||
|
||||
```sh
|
||||
# 1. Does SteamOS Manager offer the interface on this build?
|
||||
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
|
||||
busctl --user introspect com.steampowered.SteamOSManager1 \
|
||||
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
|
||||
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
|
||||
|
||||
# 2. Which power supplies exist, and which drivers back them?
|
||||
for d in /sys/class/power_supply/*; do
|
||||
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
|
||||
ls $d
|
||||
done
|
||||
|
||||
# 3. Any charge-control attributes, with their values and permissions?
|
||||
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
|
||||
# 4. What is Valve's charger daemon, and what does it touch?
|
||||
systemctl status deckard-charger.service --no-pager
|
||||
systemctl cat deckard-charger.service
|
||||
journalctl -b -u deckard-charger.service --no-pager | tail -50
|
||||
```
|
||||
|
||||
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
|
||||
on a charger at a charge above 80%. Watch `current_now` on the
|
||||
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
|
||||
confirm charging resumes. Do this on a supervised Frame only. If only step 3
|
||||
finds a vendor attribute, **stop**: don't write it, and leave the question
|
||||
with Valve.
|
||||
|
||||
## Sources
|
||||
|
||||
- steamos-manager source, commit `83981ab` (2026-10-21):
|
||||
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
|
||||
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
|
||||
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
|
||||
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
|
||||
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
|
||||
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
|
||||
the Steam toggle shows on unsupported hardware
|
||||
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
|
||||
`qcom_battmgr` threshold on other SM8650/SM8750 devices
|
||||
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
|
||||
untested on real charging
|
||||
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
|
||||
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
|
||||
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
Screenshots and Steam clips have their own buttons on **Tools → Capture &
|
||||
Live**, and **Settings → Copy to this computer** copies new ones here
|
||||
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
@@ -36,3 +40,28 @@ desktop has to be running in the headset. It's text only, and pastes over about
|
||||
|
||||
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||
headset.
|
||||
|
||||
### In Frame Control
|
||||
|
||||
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
|
||||
clipboard next to the keyboard and trackpad, both ways:
|
||||
|
||||
- **Send text** or **Send this computer's clipboard** puts text on the headset's
|
||||
clipboard, as above.
|
||||
- **Copy the headset's clipboard** brings it back: copy something in a desktop
|
||||
app on the Frame, then press it, and paste on your computer. It calls
|
||||
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
|
||||
back as base64, so accents, emoji and line breaks arrive unchanged. The
|
||||
desktop app and the iPhone app put it on their own clipboard. With the server
|
||||
in a browser, the server sets the clipboard of the computer it runs on:
|
||||
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
|
||||
`wl-copy`, `xclip` or `xsel` on Linux.
|
||||
|
||||
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
|
||||
refused with a message, so send big things as a file. An empty clipboard, or
|
||||
one holding an image, says so. Neither the server nor its log ever records what
|
||||
was on the clipboard.
|
||||
|
||||
**Not tested on a headset yet.** Reading uses the same Klipper interface as
|
||||
sending, which was verified on 2026-09-25, but `getClipboardContents` itself
|
||||
hasn't been tried on a Frame. The fake Frame (`tests/fakeframe`) answers it.
|
||||
+34
-6
@@ -25,7 +25,7 @@ settings, reports), **Tools** (in the headset: your computer's windows, the medi
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
|
||||
privacy and updates, library artwork and the assistant. The battery chip opens
|
||||
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
|
||||
build. Files can be dropped anywhere in the window. A
|
||||
@@ -49,11 +49,27 @@ counts them while they run.
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Record** what the headset shows, with its sound, to an `.mp4` in
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
|
||||
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
the desktop, Lepton and xrdp are running.
|
||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
||||
- **Force quit** a game that has stopped responding: Steam games, sideloaded
|
||||
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
|
||||
Games lists what's running; each game's own **Force quit…** (on its library
|
||||
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
|
||||
a game** card do the same. Each asks first, because unsaved progress is lost.
|
||||
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
|
||||
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
|
||||
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||
@@ -80,8 +96,14 @@ counts them while they run.
|
||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
||||
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
|
||||
Runtime picked from the program's header), listed under **Sideloaded titles**
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
|
||||
Frame clipboard.
|
||||
with Launch and Remove; see [sideloading.md](sideloading.md).
|
||||
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
|
||||
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
|
||||
Frame's apps through KDE Connect, and move clipboard text both ways. Send
|
||||
typed text or your computer's clipboard to the headset, or copy the headset's
|
||||
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
|
||||
clipboard**. The clipboard needs the desktop panel open in the headset; it's
|
||||
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
|
||||
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
|
||||
its own panel in the headset. Place it with the SteamVR dashboard, click and
|
||||
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||
@@ -127,6 +149,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Force quit a game** lists what's running only when you choose **Check what's
|
||||
running**, and says so when nothing is. Each **Force quit…** names the game and
|
||||
warns that unsaved progress will be lost before anything is sent; it uses the
|
||||
selected headset, refuses a second quit while one is running, and drops the
|
||||
list when you switch headsets.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
@@ -139,7 +167,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.
|
||||
|
||||
@@ -220,8 +248,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||
and others). The log is at
|
||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
||||
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
|
||||
`xclip` (X11).
|
||||
instead of the app, sending the clipboard, or copying the headset's to this
|
||||
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
|
||||
- Remote access from anywhere: [tailscale.md](tailscale.md)
|
||||
- Floating windows in space: [panels.md](panels.md)
|
||||
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
|
||||
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
|
||||
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
|
||||
- What's still unverified: [open-questions.md](open-questions.md)
|
||||
+3
-2
@@ -102,8 +102,9 @@ app installed opens its App Store page.
|
||||
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
|
||||
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
|
||||
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
|
||||
Sending text to the Frame's clipboard needs the desktop panel open in the
|
||||
headset, as on the desktop app.
|
||||
Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
|
||||
panel open in the headset, as on the desktop app.
|
||||
|
||||
Not yet exercised: Android display changes through podman (no Android app was
|
||||
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||
|
||||
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
|
||||
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
|
||||
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
|
||||
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only).
|
||||
only). On Windows there's an experimental port,
|
||||
[PC in the headset](pc-in-headset.md).
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
|
||||
@@ -143,6 +143,15 @@ check in the headset:
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
||||
|
||||
## Battery charge limit (2026-10-23)
|
||||
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a window or a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Place it with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
**It's experimental and not yet tried on a real Windows desktop or with a
|
||||
headset.** It builds and passes its protocol tests on Windows in CI, but desktop
|
||||
capture, Media Foundation encoding and laser input have never run on a real
|
||||
PC. Expect it not to work yet. Reports are welcome through the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/).
|
||||
|
||||
## Turning it on
|
||||
|
||||
It's off by default. Until you tick **Turn on PC in the headset** on the card,
|
||||
Frame Control starts no helper, loads none of the streaming libraries and lists
|
||||
no windows. The choice is remembered on this computer. Turning it off ends
|
||||
every stream, closes the viewers in the headset and stops the helper.
|
||||
|
||||
The card and its Tools link only appear in the Windows app. On macOS the card
|
||||
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
|
||||
|
||||
## How it works
|
||||
|
||||
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
|
||||
Frame Control owns the host helper, input, authentication, streaming protocol,
|
||||
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
|
||||
streaming app. GStreamer is bundled as ordinary libraries.
|
||||
|
||||
| | Windows |
|
||||
|---|---|
|
||||
| Capture | Windows.Graphics.Capture, through GStreamer's `d3d11screencapturesrc capture-api=wgc`, by window (HWND) or screen (HMONITOR) |
|
||||
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
|
||||
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
|
||||
|
||||
API choices are documented, not device-verified:
|
||||
[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).
|
||||
|
||||
It needs Windows 10 or 11 with Windows.Graphics.Capture and a Media Foundation
|
||||
H.264 encoder (Windows "N" editions need the Media Feature Pack). Clicks and
|
||||
keys can't reach administrator apps or the sign-in screen, which an ordinary
|
||||
process can't drive. If Windows won't bring the chosen window to the front,
|
||||
input is refused rather than sent to the app covering it: bring the window
|
||||
forward, then Stop and Show again. Minimised or closed windows stop sending
|
||||
pictures. Protected video isn't supported. Mac in the headset's "own display
|
||||
per window" is Mac-only.
|
||||
|
||||
The pieces it shares with Mac in the headset:
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
|
||||
panel launch. `ui/frame_pcview.py` picks the Windows helper
|
||||
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
|
||||
holds the on/off switch. `/api/macview` is the endpoint for both.
|
||||
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
|
||||
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
|
||||
messages are unchanged.
|
||||
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
|
||||
for the Windows helper. The Mac keeps its Swift original;
|
||||
`tests/test_pc_controller.py` checks the port against a frozen 600-state
|
||||
trace from it. Capture is gated before encoding, so encoded frames are never
|
||||
dropped, and an idle window's last picture still goes out once the link
|
||||
clears.
|
||||
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
|
||||
so the benchmark's analysis is shared.
|
||||
|
||||
## Packaging
|
||||
|
||||
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
|
||||
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
|
||||
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
|
||||
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
|
||||
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
|
||||
each copied DLL and the exact package versions; licence texts are in
|
||||
`licenses/`.
|
||||
|
||||
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
|
||||
with Media Foundation, so only CI's test builds include it
|
||||
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
|
||||
|
||||
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
|
||||
with the packages listed in `.github/workflows/pc-host.yml`:
|
||||
|
||||
```sh
|
||||
python desktop/build.py # what the installer ships
|
||||
python desktop/build.py --with-x264 # for the native tests
|
||||
```
|
||||
|
||||
A missing bundle is reported on the card; nothing is downloaded on first use.
|
||||
|
||||
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
|
||||
because it shares `capture.c` and the controller, and CI still builds and tests
|
||||
it. The app never offers it and the Linux installers don't include it.
|
||||
|
||||
## Measuring
|
||||
|
||||
The Mac benchmark accepts the PC host:
|
||||
|
||||
```sh
|
||||
python scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
`capture` needs a window or screen id from the helper's `/windows` or
|
||||
`/displays`. CPU sampling isn't available on PC hosts. See
|
||||
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
|
||||
|
||||
## What's verified
|
||||
|
||||
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
|
||||
and a real x264 synthetic stream passes the ticket, reconnect, timing and
|
||||
input-echo tests through the helper. The Windows installer builds with the
|
||||
bundle, and its bundled Python loads the bundle, starts the helper and
|
||||
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
|
||||
gating, the card and the Tools link on macOS, Windows and Linux.
|
||||
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
|
||||
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
|
||||
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
|
||||
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
|
||||
revision `cd20243`. The frame-rate and late-frame targets failed. This
|
||||
proves the helper and the viewer work together; it isn't Windows capture or
|
||||
a real PC link.
|
||||
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
|
||||
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
|
||||
Swift controller's congestion and recovery trace.
|
||||
- **Not verified:** anything on a real Windows desktop. Windows.Graphics.Capture,
|
||||
Media Foundation (hardware or software), encoder restarts when the bitrate
|
||||
changes, mixed-DPI and multi-monitor input alignment, window resize and
|
||||
minimise, non-US keyboard layouts, physical laser input, and how it performs
|
||||
in a worn headset. No numbers are claimed for these. Not tested with a
|
||||
headset from this branch.
|
||||
- **Not reviewed independently.** #38 tried an independent review that never
|
||||
completed.
|
||||
@@ -287,3 +287,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
|
||||
## Screenshots and clips
|
||||
|
||||
Copying screenshots and Steam clips to your computer, by hand or with
|
||||
**Settings → Copy to this computer**, goes straight from the headset to your
|
||||
computer over the same SSH connection as everything else. Nothing is uploaded
|
||||
anywhere. What automatic copying has copied is listed, by file id, in
|
||||
`auto-sync.json` in Frame Control's data folder; see
|
||||
[recording.md](recording.md#copying-new-ones-automatically).
|
||||
@@ -0,0 +1,164 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
|
||||
Nothing needs installing on the computer or on the Frame: the work is done by
|
||||
the `ffmpeg` that SteamOS ships.
|
||||
|
||||
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
|
||||
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
|
||||
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
|
||||
second, and nothing was left running or stored on the Frame. That Frame had no
|
||||
Steam recordings, so the clip list came back empty and export has only been
|
||||
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
|
||||
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
|
||||
|
||||
## Record
|
||||
|
||||
Press **Record** next to **Live**. The button turns into **Stop**, and a line
|
||||
under the viewer counts the time. Press **Stop**: the recording is finished on
|
||||
the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
|
||||
|
||||
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
|
||||
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
|
||||
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
|
||||
The sound is what the headset plays: the monitor of PipeWire's default
|
||||
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
|
||||
The microphone isn't recorded.
|
||||
- **Without sound**: before each recording Frame Control checks for a fraction
|
||||
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
|
||||
ahead as video only, and the page and the saved message say so.
|
||||
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
|
||||
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
|
||||
a full-length recording twice, while it's converted), and checks the space on
|
||||
your computer before copying. One recording runs at a time.
|
||||
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
|
||||
which is a RAM disk on SteamOS. It's deleted once copied.
|
||||
- **If the connection drops or the app quits** while recording, the Frame
|
||||
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
|
||||
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
|
||||
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
|
||||
SIGINT and carries on recording. A recording that was never copied is
|
||||
deleted when the next one starts.
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
```
|
||||
~/.local/share/Steam/userdata/<account>/gamerecordings/
|
||||
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
|
||||
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
|
||||
session.mpd
|
||||
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
|
||||
init-stream1.m4s chunk-stream1-00001.m4s ...
|
||||
```
|
||||
|
||||
Each stream's `init-stream` segment followed by its chunks, in number order, is
|
||||
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
|
||||
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
|
||||
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
|
||||
copied to the computer and deleted from the Frame. The export needs free space
|
||||
on the Frame of about twice the recording's size while it runs.
|
||||
|
||||
The listing doesn't rely on the folder names: any folder under `clips/` or
|
||||
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
|
||||
missing its init segment or its chunks is left out. Missing chunk numbers are
|
||||
shown as "pieces missing"; the rest still exports.
|
||||
|
||||
## Copying new ones automatically
|
||||
|
||||
**Settings → Copy to this computer** copies new screenshots and Steam clips
|
||||
here on their own. It's off until you turn it on, with a switch each for
|
||||
screenshots and Steam clips. While a headset is connected, Frame Control looks
|
||||
about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
|
||||
`auto-sync.json` in Frame Control's data folder, so a file you delete here
|
||||
isn't copied again (**Copy everything now** would bring it back).
|
||||
- **Saved clips only.** Steam's background recording is a rolling buffer, not
|
||||
something you chose to keep, so it isn't copied; export it by hand. A clip
|
||||
Steam wrote to in the last 30 seconds is still being saved and waits for the
|
||||
next look.
|
||||
- **It gives way.** It waits while an install, an export, a recording or
|
||||
anything else is running on the headset, and stops between files if one
|
||||
starts. While a copy is running, switching headsets waits for it, as it does
|
||||
for an export. When the headset can't be reached it waits longer each time,
|
||||
up to 15 minutes, and starts over once it answers.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
|
||||
names only, no `.` or `..`) before it reaches a path or a shell.
|
||||
|
||||
The API, with the same guards as every other request:
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
|
||||
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
|
||||
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
|
||||
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
|
||||
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
|
||||
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
|
||||
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
|
||||
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
|
||||
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## What hasn't been checked
|
||||
|
||||
None of this has run on a Steam Frame yet. In particular:
|
||||
|
||||
- That SteamOS's `ffmpeg` can read `@DEFAULT_MONITOR@`. It has the PulseAudio
|
||||
output device (the media player plays through it), so the input is expected,
|
||||
but if it fails the recording is video only, as above.
|
||||
- Audio and video sync. Both inputs are stamped with the wall clock, because
|
||||
the camera device and the audio server otherwise count from different clocks.
|
||||
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
|
||||
whether a clip's segments start mid-recording. The parser is deliberately
|
||||
loose about the layout.
|
||||
- How much x264 at 1080p30 costs the Frame while a game is running.
|
||||
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
|
||||
finished saving a clip, and how a once-a-minute listing feels on the Frame.
|
||||
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
|
||||
run it for testing without the headset. For recovering a Frame that won't boot,
|
||||
see the boot menu and boot-loop entries in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
|
||||
To keep your game saves, settings and Android app data through a factory reset
|
||||
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
|
||||
[backup-and-recovery.md](backup-and-recovery.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
|
||||
@@ -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**) |
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ it.) A dialog shows:
|
||||
|
||||
Install copies it to the Frame and registers it with Steam; progress shows in
|
||||
the bar and the activity log. **Sideloaded titles** lists what's installed,
|
||||
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
|
||||
with Launch, Remove and, for a game that has stopped responding, **Force
|
||||
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
|
||||
behaviour for a zip that isn't a game.
|
||||
|
||||
From a terminal:
|
||||
|
||||
+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
|
||||
|
||||
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
|
||||
returns nothing without `cc`, so Frame Control takes the country from
|
||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
||||
|
||||
## Force quitting a running game
|
||||
|
||||
**Running now**, at the top of Games, lists what's running on the Frame: Steam
|
||||
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
|
||||
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
|
||||
card have **Force quit…** too. Each asks first ("Unsaved progress will be
|
||||
lost"); from a game's own menu it checks that the game is running and says so if
|
||||
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
|
||||
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
|
||||
|
||||
`ui/frame_running.py` does the work on the Frame, piped over SSH with
|
||||
`frame_steam.py` as its DevTools client:
|
||||
|
||||
```sh
|
||||
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
|
||||
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
|
||||
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
|
||||
```
|
||||
|
||||
1. **What's running.** Steam starts every game, shortcut and devkit title under
|
||||
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
|
||||
stays the parent of the game's processes. So a running game is one of your
|
||||
own processes whose program is named `reaper`, with `SteamLaunch` and an
|
||||
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
|
||||
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
|
||||
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
|
||||
the Android app's `meta.json` or the devkit title's folder.
|
||||
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
|
||||
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
|
||||
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
|
||||
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
|
||||
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
|
||||
3. **Then signals.** If the game's processes are still there after 5 seconds,
|
||||
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
|
||||
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
|
||||
is only signalled while its start time still matches, so a reused pid is safe.
|
||||
|
||||
Nothing is found by name. These are never signalled, even inside a game's tree:
|
||||
init, the helper itself and every process above it (the SSH session), other
|
||||
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
|
||||
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
|
||||
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
|
||||
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
|
||||
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
|
||||
`vrwebhelper` or `vrstartup`, with everything under them.
|
||||
|
||||
**Not tested on a headset.** The reaper command line is how Steam launches games
|
||||
on Linux desktops and the Steam Deck, **inferred** for the Frame.
|
||||
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
|
||||
it and hasn't been seen on the Frame's build; without it, the reapers alone
|
||||
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
|
||||
seen making for an Android app (docs/vr-apks.md); for Steam games it is
|
||||
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
|
||||
process selection and the escalation; the fake Frame launches its games under a
|
||||
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
|
||||
|
||||
## Not yet checked
|
||||
|
||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ capture it.
|
||||
|
||||
## Input: type and point in the Frame from the Mac or iPhone
|
||||
|
||||
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
|
||||
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
|
||||
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
|
||||
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
|
||||
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
|
||||
+45
-2
@@ -76,8 +76,8 @@ its guards want.
|
||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
||||
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
|
||||
| Valve's devkit-utils, copied over by Frame Control itself | Real |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
|
||||
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
|
||||
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
|
||||
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
|
||||
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
|
||||
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||
@@ -187,6 +187,27 @@ 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
|
||||
|
||||
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
|
||||
that only Windows gets the PC host and that it starts nothing until turned on,
|
||||
the Tools link and card wording, source-bound tickets, reconnect/Stop
|
||||
revocation, input release, source-coordinate mapping and use of the existing
|
||||
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
|
||||
against a frozen trace of the Mac's Swift one.
|
||||
|
||||
The `PC host libraries` workflow builds the native adapter on Windows (and
|
||||
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
|
||||
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
|
||||
CI builds include it (`desktop/build.py --with-x264`); the release workflow
|
||||
tests the installer's own bundle with its bundled Python and checks x264 isn't
|
||||
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
|
||||
bundle or encoder fail CI instead of skipping.
|
||||
|
||||
None of this captures a desktop, exercises Media Foundation or a GPU, or
|
||||
checks laser alignment. The [Windows test VM](#windows-test-vm) is the next
|
||||
step for that. See [what's verified](pc-in-headset.md#whats-verified).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
@@ -267,6 +288,17 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Force quit
|
||||
|
||||
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
|
||||
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
|
||||
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
|
||||
sshd and session processes that must never be signalled. Processes that ignore
|
||||
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
|
||||
The Control Center's confirmation, duplicate and headset-switch handling run in
|
||||
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
|
||||
trees on the fake Frame. None of it has run on a headset.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
@@ -314,3 +346,14 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
|
||||
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
|
||||
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
|
||||
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
|
||||
|
||||
## Backup and recovery kit
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
|
||||
for real against a temporary folder laid out like the Frame's home, with only
|
||||
SSH faked: path selection and the secrets it must never copy, the manifest and
|
||||
checksums, restore checks after a reset, restore writing only its own files,
|
||||
unsafe archives and backup names, and the exact Game Mode command.
|
||||
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
|
||||
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
|
||||
No backup or restore has been run on a real headset yet.
|
||||
@@ -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,22 @@ 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"),
|
||||
writeClipboard: (text) => call("writeClipboard", text),
|
||||
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 +69,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,8 +134,20 @@ 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 "writeClipboard":
|
||||
guard let text = arg as? String else { return replyHandler(nil, "Expected text") }
|
||||
UIPasteboard.general.string = text
|
||||
replyHandler(true, nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
@@ -135,9 +163,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 +184,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 +235,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"]
|
||||
|
||||
+55
-13
@@ -55,6 +55,7 @@ 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"
|
||||
@@ -254,7 +255,10 @@ class LabView(frame_macview.MacView):
|
||||
|
||||
def ssh_runner(base):
|
||||
def run(remote, stdin=None, timeout=30):
|
||||
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
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")
|
||||
if r.returncode:
|
||||
e = RuntimeError((r.stderr or r.stdout).strip())
|
||||
e.stdout = r.stdout
|
||||
@@ -381,7 +385,11 @@ 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 = "test"
|
||||
src = args.source or "test"
|
||||
cpu = None
|
||||
if args.pc:
|
||||
mv.host = "windows" if sys.platform == "win32" else "linux"
|
||||
mv.viewer_profile = "pc-view"
|
||||
try:
|
||||
if scenario in ("scroll", "type"):
|
||||
chrome = chrome_window(f"{scenario}.html")
|
||||
@@ -405,9 +413,10 @@ 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"]
|
||||
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 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)
|
||||
if relay:
|
||||
relay.begin()
|
||||
expected = 0 # input events the harness asked the viewer for
|
||||
@@ -443,7 +452,8 @@ 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]
|
||||
cpu.stop()
|
||||
if cpu:
|
||||
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)
|
||||
@@ -452,7 +462,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()
|
||||
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
|
||||
result["viewer"] = data["summary"].get("decoder", "")
|
||||
result["show_s"] = show_s
|
||||
result["panel"] = shown.get("panel")
|
||||
@@ -460,6 +470,8 @@ 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
|
||||
@@ -670,6 +682,8 @@ 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))
|
||||
@@ -717,7 +731,7 @@ def frame_ssh_for(args):
|
||||
|
||||
|
||||
def run_suite(args, frame_ssh, quiet=False):
|
||||
agent_port, token = lab_agent()
|
||||
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
|
||||
results = []
|
||||
for sc in args.scenario.split(","):
|
||||
if not quiet:
|
||||
@@ -744,11 +758,12 @@ 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": args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
|
||||
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
|
||||
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
|
||||
"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:],
|
||||
"scenarios": results, **extra,
|
||||
}
|
||||
|
||||
@@ -819,19 +834,46 @@ def main():
|
||||
print("\nworse:\n " + "\n ".join(regs))
|
||||
sys.exit(1 if regs else 0)
|
||||
|
||||
lab_agent()
|
||||
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()
|
||||
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))])
|
||||
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
|
||||
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:
|
||||
awake.terminate()
|
||||
if args.pc_view:
|
||||
args.pc_view.shutdown()
|
||||
if awake:
|
||||
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');
|
||||
});
|
||||
+76
-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(){},resetClips(){},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, runningGames = [], runningSeq = 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,77 @@ 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);
|
||||
});
|
||||
// Force quit: an explicit check, a confirmation naming the game, and the captured headset.
|
||||
function quitPage({confirmAnswer=true, running=[{appid:620,name:'Portal <2>',kind:'steam'}], quit} = {}) {
|
||||
const asked=[], calls=[];
|
||||
const f=mounted({confirm:text=>{asked.push(text);return confirmAnswer;},
|
||||
api:async(path,body,device)=>{calls.push({path,body,device});
|
||||
if(body===undefined)return {running};
|
||||
return quit ? quit(body) : {message:'Quit Portal 2',method:'steam'};}});
|
||||
return Object.assign(f,{asked,apiCalls:calls,click:appid=>f.element('ccRunningList').onclick({target:{closest:()=>({dataset:{ccQuit:String(appid)}})}})});
|
||||
}
|
||||
test('force quit lists what is running and says so when nothing is', async t => {
|
||||
const f=quitPage({running:[]});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
assert.deepEqual(f.apiCalls,[{path:'/api/running',body:undefined,device:'frame-a'}]);
|
||||
assert.equal(f.element('ccRunningResult').textContent,'Nothing is running on the Frame.');
|
||||
assert.equal(f.element('ccRunningList').innerHTML,'');
|
||||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();
|
||||
assert.match(g.element('ccRunningList').innerHTML,/Portal <2><small>Steam game<\/small>.*data-cc-quit="620"/);
|
||||
assert.match(g.element('ccRunningResult').textContent,/^1 running/);
|
||||
});
|
||||
test('force quit confirms with the unsaved-progress warning before any request', async t => {
|
||||
const f=quitPage({confirmAnswer:false});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
f.click(620);await flush();
|
||||
assert.deepEqual(f.asked,['Force quit Portal <2>? Unsaved progress will be lost.']);
|
||||
assert.equal(f.apiCalls.length,1,'declining sends nothing');
|
||||
const g=quitPage();t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();
|
||||
g.click(620);await flush();
|
||||
assert.deepEqual(g.apiCalls[1],{path:'/api/running',body:{action:'quit',appid:'620'},device:'frame-a'});
|
||||
assert.equal(g.element('ccRunningResult').textContent,'Quit Portal 2');
|
||||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||||
g.click(999);await flush();
|
||||
assert.equal(g.apiCalls.length,2,'an id that was not listed is ignored');
|
||||
});
|
||||
test('force quit shows errors, refuses duplicates and offline use, and ignores a switched headset', async t => {
|
||||
const pending=deferred();
|
||||
const f=quitPage({quit:()=>pending.promise});t.after(()=>f.ui.destroy());
|
||||
await f.element('ccRunningCheck').onclick();
|
||||
f.click(620);f.click(620);await flush();
|
||||
assert.equal(f.asked.length,1);
|
||||
assert.match(f.element('ccRunningList').innerHTML,/disabled/);
|
||||
pending.reject(Error("That game isn't running any more"));await flush();
|
||||
assert.equal(f.element('ccRunningResult').textContent,"Couldn’t force quit Portal <2>: That game isn't running any more");
|
||||
assert.doesNotMatch(f.element('ccRunningList').innerHTML,/disabled/);
|
||||
// A switch while quitting: the old answer is dropped and the list cleared.
|
||||
const late=deferred(),g=quitPage({quit:()=>late.promise});t.after(()=>g.ui.destroy());
|
||||
await g.element('ccRunningCheck').onclick();g.click(620);await flush();
|
||||
g.context={online:true,generation:2,device:'frame-b'};g.ui.reset();
|
||||
late.resolve({message:'Quit Portal 2'});await flush();
|
||||
assert.equal(g.element('ccRunningResult').textContent,'');
|
||||
assert.equal(g.element('ccRunningList').innerHTML,'');
|
||||
const h=quitPage();t.after(()=>h.ui.destroy());h.context={online:false,generation:1,device:'frame-a'};
|
||||
await h.element('ccRunningCheck').onclick();
|
||||
assert.equal(h.apiCalls.length,0);
|
||||
assert.match(h.element('ccRunningResult').textContent,/Connect to the Frame/);
|
||||
const controller=cc.createController({api:async()=>assert.fail('no request'),getContext:()=>({online:true,generation:1})});
|
||||
await assert.rejects(controller.forceQuit({appid:'620; reboot'}),/Choose a running game/);
|
||||
});
|
||||
test('the Control Center finds force quit by what a stuck game looks like', () => {
|
||||
const item=cc.catalog.find(i=>i.id==='forcequit');
|
||||
for(const q of ['frozen game','hung','not responding','kill','stuck app'])assert.ok(cc.matches(item,q,'all'),q);
|
||||
assert.ok(cc.matches(item,'','apps'));
|
||||
});
|
||||
@@ -45,6 +45,13 @@ class Device(harness.FrameTestCase):
|
||||
self.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
|
||||
self.assertEqual(state()['clipboard'], [text])
|
||||
|
||||
def test_clipboard_comes_back_from_klipper(self):
|
||||
text = 'from the headset: 世界 🎧\n\nwith trailing newlines\n\n'
|
||||
ok('POST', '/api/clipboard', {'text': text})
|
||||
out = ok('POST', '/api/clipboard', {'read': True})
|
||||
self.assertEqual(out['text'], text)
|
||||
self.assertRegex(out['message'], r"^Copied \d+ characters from the headset's clipboard$")
|
||||
|
||||
def test_flatpak_install_and_remove(self):
|
||||
# Installs run as background jobs (server.start_job); uninstall answers at once.
|
||||
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Backup and recovery kit through the server against the fake Frame: a Proton game's save
|
||||
goes to this computer and back, Steam's sign-in token never leaves the headset."""
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import harness
|
||||
from harness import api, finished, ok, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
SAVE = '.local/share/Steam/steamapps/compatdata/620980/pfx/drive_c/users/steamuser/AppData/LocalLow/Beat Games/Beat Saber/PlayerData.dat'
|
||||
|
||||
|
||||
class Backup(harness.FrameTestCase):
|
||||
def test_saves_round_trip_and_secrets_stay(self):
|
||||
ssh(f"mkdir -p ~/'{Path(SAVE).parent}' && printf 'level 7' > ~/'{SAVE}' && "
|
||||
"mkdir -p ~/.steam && printf SECRET > ~/.steam/steam.token")
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'backup', 'kinds': ['saves', 'settings']}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
folder = Path(job['result']['path'])
|
||||
self.addCleanup(shutil.rmtree, str(folder), True)
|
||||
manifest = json.loads((folder / 'manifest.json').read_text())
|
||||
self.assertEqual(manifest['headset']['build'], '20260922.6101926')
|
||||
saves = next(p for p in manifest['parts'] if p['id'] == 'saves:620980')
|
||||
self.assertEqual(saves['label'], 'Beat Saber saves')
|
||||
self.assertEqual([f['path'] for f in saves['files']], [SAVE])
|
||||
for part in manifest['parts']:
|
||||
with tarfile.open(str(folder / part['archive'])) as tar:
|
||||
self.assertFalse([m.name for m in tar if 'steam.token' in m.name])
|
||||
self.assertEqual(ssh('cat ~/.steam/steam.token'), 'SECRET') # read-only on the headset
|
||||
|
||||
ssh(f"rm ~/'{SAVE}'")
|
||||
name = folder.name
|
||||
plan = {p['id']: p for p in ok('POST', '/api/backup', {'action': 'plan', 'name': name})['parts']}
|
||||
self.assertEqual((plan['saves:620980']['status'], plan['saves:620980']['new']), ('ok', 1))
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980']})
|
||||
self.assertEqual(status, 400, out) # not confirmed
|
||||
job = finished(ok('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980'],
|
||||
'confirm': True}), 120)
|
||||
self.assertIsNone(job['error'], job)
|
||||
self.assertEqual(ssh(f"cat ~/'{SAVE}'"), 'level 7')
|
||||
|
||||
def test_bad_names_and_session(self):
|
||||
status, out, _ = api('POST', '/api/backup', {'action': 'plan', 'name': '../../etc'})
|
||||
self.assertEqual(status, 400, out)
|
||||
state = ok('POST', '/api/backup', {'action': 'session'})
|
||||
self.assertIn(state['mode'], ('game', 'desktop', 'unknown'))
|
||||
self.assertIsNone(state['selector']) # the fake has no session switch command
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Force quit (ui/frame_running.py) through the server's HTTP API, against games the
|
||||
fake Steam client really runs under its stand-in reaper (fakesteam.REAPER)."""
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import harness
|
||||
from harness import api, install_title, launches, ok, ssh, state, wait_for
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
class Running(harness.FrameTestCase):
|
||||
def script_title(self, name, body):
|
||||
folder = self.path(name)
|
||||
os.makedirs(folder)
|
||||
with open(os.path.join(folder, 'run.sh'), 'w') as f:
|
||||
f.write('#!/bin/sh\n' + body)
|
||||
os.chmod(os.path.join(folder, 'run.sh'), 0o755)
|
||||
plan, job = install_title(folder)
|
||||
self.assertIsNone(job['error'], job)
|
||||
return plan['id'], plan['name']
|
||||
|
||||
def running(self, match):
|
||||
return next((g for g in ok('GET', '/api/running')['running'] if match(g)), None)
|
||||
|
||||
def launch_title(self, gid):
|
||||
ok('POST', '/api/titles', {'action': 'launch', 'id': gid})
|
||||
game = wait_for(lambda: self.running(lambda g: g.get('title') == gid), 20, f'{gid} to be running')
|
||||
# The fake's reset doesn't stop programs: don't leave one running for the next test.
|
||||
self.addCleanup(ssh, f"pkill -KILL -f '[d]evkit-game/{gid}/' || true", check=False)
|
||||
return game
|
||||
|
||||
def assert_steam_alive(self, pid):
|
||||
self.assertEqual(ssh(f'kill -0 {pid} && echo alive').strip(), 'alive')
|
||||
|
||||
def test_nothing_running(self):
|
||||
self.assertEqual(ok('GET', '/api/running'), {'running': [], 'message': 'Nothing is running on the Frame'})
|
||||
status, out, _ = api('POST', '/api/running', {'action': 'quit', 'appid': '620'})
|
||||
self.assertEqual(status, 409, out)
|
||||
|
||||
def test_sigterm_quits_a_sideloaded_game(self):
|
||||
gid, name = self.script_title('Hang Game', 'sleep 600 &\nwait\n')
|
||||
steam = ssh('cat ~/.steam/steam.pid').strip()
|
||||
game = self.launch_title(gid)
|
||||
self.assertEqual((game['kind'], game['name']), ('devkit', name))
|
||||
self.assertGreaterEqual(game['processes'], 3) # reaper, run.sh, sleep
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
|
||||
self.assertEqual(out['method'], 'term', out)
|
||||
self.assertEqual(out['message'], f'Force quit {name}')
|
||||
# Steam was asked first, with the shortcut's game id.
|
||||
self.assertEqual(state()['steam']['last_terminate'], game['gameid'])
|
||||
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
|
||||
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
|
||||
self.assert_steam_alive(steam)
|
||||
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
|
||||
10, 'the reaper to report the exit')
|
||||
self.assertIsNotNone(done['exit'])
|
||||
|
||||
def test_sigkill_when_it_ignores_sigterm(self):
|
||||
gid, name = self.script_title('Stuck Game', "trap '' TERM\nsleep 600 &\nwait\n")
|
||||
steam = ssh('cat ~/.steam/steam.pid').strip()
|
||||
game = self.launch_title(gid)
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
|
||||
self.assertEqual(out['method'], 'kill', out)
|
||||
self.assertEqual(out['message'], f'Force quit {name} (it had to be killed)')
|
||||
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
|
||||
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
|
||||
self.assert_steam_alive(steam)
|
||||
|
||||
def test_steam_game_quits_through_steam(self):
|
||||
ok('POST', '/api/launch', {'appid': 620980})
|
||||
game = wait_for(lambda: self.running(lambda g: g['appid'] == 620980), 20, 'the Steam game to be running')
|
||||
self.assertEqual((game['kind'], game['gameid']), ('steam', '620980'))
|
||||
out = ok('POST', '/api/running', {'action': 'quit', 'appid': '620980'})
|
||||
self.assertEqual(out['method'], 'steam', out)
|
||||
self.assertEqual(state()['steam']['last_terminate'], '620980')
|
||||
self.assertEqual(launches('rungameid')[-1]['exit'], 'terminated')
|
||||
self.assertEqual(ok('GET', '/api/running')['running'], [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
|
||||
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
|
||||
verified 2026-09-25)."""
|
||||
verified 2026-09-25), and answers getClipboardContents with the last one
|
||||
(server.COPY). Printing the string and a newline is how qdbus prints a
|
||||
returned QString; getClipboardContents itself is a guess, not seen on a headset."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -15,5 +17,10 @@ if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipb
|
||||
sys.exit('Could not connect to D-Bus server')
|
||||
with fs.update() as s:
|
||||
s['clipboard'].append(args[3])
|
||||
elif args == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.getClipboardContents']:
|
||||
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
|
||||
sys.exit('Could not connect to D-Bus server')
|
||||
clips = fs.read()['clipboard']
|
||||
sys.stdout.buffer.write((clips[-1] if clips else '').encode() + b'\n')
|
||||
else:
|
||||
sys.exit(f'qdbus6 stub: unsupported {args}')
|
||||
@@ -79,6 +79,12 @@ function build(steam) {
|
||||
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||
},
|
||||
},
|
||||
// Guess: the running list as Steam's client code and plugin loaders read it
|
||||
// (SteamUIStore.RunningApps of app overviews); not seen on the Frame. fakesteam
|
||||
// passes the ids of launches that haven't exited.
|
||||
SteamUIStore: {
|
||||
get RunningApps() { return (steam.running || []).map(id => allApps().find(a => a.appid === id) || { appid: id }); },
|
||||
},
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
@@ -109,7 +115,8 @@ function build(steam) {
|
||||
if (s?.artwork) delete s.artwork[type];
|
||||
},
|
||||
// Container fallback in frame_android.stop performs the simulated stop.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||
// fakesteam ends pid-less launches for the ids in `terminated`.
|
||||
TerminateApp(gameid) { steam.last_terminate = gameid; (steam.terminated ||= []).push(String(gameid)); },
|
||||
RemoveShortcut(id) {
|
||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||
delete steam.compat_tools[String(id)];
|
||||
|
||||
@@ -47,6 +47,12 @@ COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
|
||||
DEVTOOLS_PORT = 8080
|
||||
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
|
||||
GAME_LOGS = '/var/log/fakeframe'
|
||||
# Steam starts a game under its reaper, `reaper SteamLaunch AppId=<id> -- <command>`,
|
||||
# which stays the game's parent until it ends (guess for the Frame: that's how Steam
|
||||
# launches on Linux desktops and the Steam Deck; docs/steam-games.md). The fake's
|
||||
# reaper is bash with that command line: it waits for the game and exits with its status.
|
||||
REAPER = fs.STEAM_ROOT + '/ubuntu12_32/reaper'
|
||||
REAPER_SH = 'while [ "$1" != "--" ]; do shift; done; shift; "$@" & wait $!'
|
||||
|
||||
# The 403 text /register returned while Steam wasn't on "Pair new host"
|
||||
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
|
||||
@@ -105,11 +111,24 @@ class JS:
|
||||
self._start()
|
||||
self.next += 1
|
||||
with fs.update() as state:
|
||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
|
||||
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
|
||||
# What Steam counts as running: launches that started and haven't exited.
|
||||
running = sorted({l['appid'] for l in state['launches']
|
||||
if l.get('started') and l.get('appid') and l.get('exit') is None})
|
||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression, 'awaitPromise': await_promise,
|
||||
'steam': dict(state['steam'], running=running)}) + '\n')
|
||||
self.proc.stdin.flush()
|
||||
reply = json.loads(self.proc.stdout.readline())
|
||||
state['steam'] = reply['steam']
|
||||
steam = reply['steam']
|
||||
steam.pop('running', None)
|
||||
for gid in map(int, steam.pop('terminated', [])):
|
||||
appid = gid >> 32 if gid >= 1 << 32 else gid
|
||||
# Guess: TerminateApp ends a launch the fake didn't really run (a Steam
|
||||
# game, a Proton title). A real process it leaves alone, so Frame
|
||||
# Control's own fallback (SIGTERM, SIGKILL) is what the e2e tests see.
|
||||
for l in state['launches']:
|
||||
if l.get('appid') == appid and l.get('exit') is None and not l.get('pid'):
|
||||
l['exit'] = 'terminated'
|
||||
state['steam'] = steam
|
||||
return reply['result']
|
||||
|
||||
|
||||
@@ -253,8 +272,10 @@ def add_launch(record):
|
||||
os.makedirs(GAME_LOGS, exist_ok=True)
|
||||
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
||||
try:
|
||||
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
||||
argv = [REAPER, '-c', REAPER_SH, 'reaper', 'SteamLaunch', f"AppId={record['appid']}", '--', path]
|
||||
proc = subprocess.Popen(argv, executable='/bin/bash', cwd=cwd if os.path.isdir(cwd) else HOME,
|
||||
env={**os.environ, **env}, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
record['pid'] = proc.pid
|
||||
except OSError as e:
|
||||
record.update(pid=None, exec_error=str(e))
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
|
||||
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
|
||||
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
|
||||
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
|
||||
`traits` array it handles; `p_android` there is assumed, not documented.
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"page": 1,
|
||||
"per_page": 500,
|
||||
"owned_keys": [
|
||||
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
|
||||
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
|
||||
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
|
||||
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
|
||||
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
|
||||
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
|
||||
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
|
||||
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
|
||||
"url": "https://maker.itch.io"}}},
|
||||
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
|
||||
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
|
||||
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
|
||||
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
|
||||
"type": "default", "classification": "game", "min_price": 0,
|
||||
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
|
||||
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
|
||||
]
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"page": 2,
|
||||
"per_page": 500,
|
||||
"owned_keys": [
|
||||
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
|
||||
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
|
||||
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
|
||||
"type": "default", "classification": "game", "min_price": 1299,
|
||||
"traits": ["p_windows", "p_android", "can_be_bought"],
|
||||
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
|
||||
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
|
||||
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
|
||||
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
|
||||
"cover_url": "http://img.example/not-itch.png",
|
||||
"type": "default", "classification": "game", "min_price": 0,
|
||||
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
|
||||
"user": {"id": 5, "username": "toys"}}}
|
||||
]
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"uploads": [
|
||||
{"id": 501, "game_id": 341190, "filename": "OrbitGarden-win64.zip", "display_name": "Windows",
|
||||
"size": 734003200, "storage": "hosted", "type": "default", "traits": ["p_windows"],
|
||||
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-02T10:00:00Z"},
|
||||
{"id": 502, "game_id": 341190, "filename": "OrbitGarden-Quest-old.apk", "display_name": "Quest 1.0",
|
||||
"size": 104857600, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-05T10:00:00Z"},
|
||||
{"id": 503, "game_id": 341190, "filename": "OrbitGarden-Quest.apk", "display_name": "Quest 1.2",
|
||||
"size": 115343360, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-03T10:00:00Z"},
|
||||
{"id": 504, "game_id": 341190, "filename": "OrbitGarden-demo.apk", "display_name": "Quest demo",
|
||||
"size": 52428800, "storage": "hosted", "type": "default", "traits": ["p_android", "demo"],
|
||||
"created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:00:00Z"},
|
||||
{"id": 505, "game_id": 341190, "filename": "OrbitGarden-android-bundle.zip", "display_name": "Android (zip)",
|
||||
"size": 120000000, "storage": "hosted", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 506, "game_id": 341190, "filename": "Soundtrack.apk", "display_name": "Soundtrack",
|
||||
"size": 1000, "storage": "hosted", "type": "soundtrack", "traits": [],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 507, "game_id": 341190, "filename": "mirror.apk", "display_name": "Mirror",
|
||||
"size": 1000, "storage": "external", "host": "example.com", "type": "default", "traits": ["p_android"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
|
||||
{"id": 508, "game_id": 341190, "filename": "OrbitGarden-mac.dmg", "display_name": "macOS",
|
||||
"size": 1000, "storage": "hosted", "type": "default", "traits": ["p_osx"],
|
||||
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -19,13 +19,13 @@ function grab(name) {
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
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,15 +56,12 @@ 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 game the user owns on itch.io says so instead of "Free".
|
||||
// A catalogue listing that is already installed offers its management page.
|
||||
{
|
||||
const p = page();
|
||||
p.sourceState.detail = { app: { name: "Orbit Garden", offers: [{ ...offer, source: "itch-owned", source_name: "itch.io (my purchases)",
|
||||
owned: true, owned_label: "Owned on itch.io", free: false }] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== true) fail("an owned listing reported a render failure");
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('<div class="detail-price">Owned on itch.io</div>')) fail("owned listing has no Owned on itch.io label");
|
||||
if (html.includes("No purchase needed")) fail("owned listing claims no purchase is needed");
|
||||
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.
|
||||
|
||||
@@ -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,291 @@
|
||||
"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
|
||||
|
||||
The headset is a fake: lists of shots and clips, and save/export functions that
|
||||
record what they were asked to copy. No SSH, no headset.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
|
||||
NOW = 1_800_000_000.0
|
||||
GB = 1024**3
|
||||
|
||||
|
||||
def shot(n, t, saved=False):
|
||||
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
|
||||
|
||||
|
||||
def clip(n, t, idle=600, kind="clip", saved=False):
|
||||
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self):
|
||||
self.shots, self.clips = [], []
|
||||
self.saved, self.exported, self.calls = [], [], []
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self):
|
||||
self.calls.append("list_shots")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"shots": list(self.shots)}
|
||||
|
||||
def list_clips(self):
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"clips": list(self.clips)}
|
||||
|
||||
def save_shots(self, device, ids):
|
||||
self.calls.append("save_shots")
|
||||
if self.save_error:
|
||||
raise self.save_error
|
||||
self.saved.append((device, list(ids)))
|
||||
for s in self.shots:
|
||||
if s["id"] in ids:
|
||||
s["saved"] = True
|
||||
|
||||
def export_clip(self, device, clip_id):
|
||||
self.calls.append("export_clip")
|
||||
if self.export_error:
|
||||
raise self.export_error
|
||||
self.exported.append((device, clip_id))
|
||||
for c in self.clips:
|
||||
if c["id"] == clip_id:
|
||||
c["saved"] = True
|
||||
|
||||
|
||||
class AutoSyncTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.frame = FakeFrame()
|
||||
self.now = [NOW]
|
||||
self.free = [100 * GB]
|
||||
self.sync = self.make()
|
||||
|
||||
def make(self):
|
||||
f = self.frame
|
||||
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
|
||||
lambda: f.device, lambda: f.busy,
|
||||
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
|
||||
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
|
||||
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
|
||||
space=lambda folder: self.free[0])
|
||||
|
||||
def test_off_by_default_and_does_nothing(self):
|
||||
self.frame.shots = [shot(1, NOW + 10)]
|
||||
self.frame.clips = [clip(1, NOW + 10)]
|
||||
s = self.sync.state()
|
||||
self.assertFalse(s["screenshots"] or s["clips"])
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.calls, []) # not even a listing
|
||||
self.assertFalse((self.dir / "auto-sync.json").exists())
|
||||
|
||||
def test_settings_persist(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
again = self.make().state()
|
||||
self.assertTrue(again["screenshots"])
|
||||
self.assertFalse(again["clips"])
|
||||
self.assertEqual(again["since"], NOW)
|
||||
saved = json.loads((self.dir / "auto-sync.json").read_text())
|
||||
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
|
||||
|
||||
def test_only_what_is_newer_than_turning_it_on(self):
|
||||
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
|
||||
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
|
||||
s = self.sync.state()
|
||||
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
|
||||
|
||||
def test_watermark_moves_when_turned_off_and_on_again(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.now[0] += 1000
|
||||
self.sync.update({"clips": True}) # already on: the watermark stays
|
||||
self.assertEqual(self.sync.state()["since"], NOW)
|
||||
self.sync.update({"screenshots": False, "clips": False})
|
||||
self.sync.update({"screenshots": True})
|
||||
self.assertEqual(self.sync.state()["since"], NOW + 1000)
|
||||
|
||||
def test_copy_everything_now_once(self):
|
||||
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
|
||||
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
|
||||
self.sync.copy_all() # works with the toggles off too
|
||||
self.assertTrue(self.sync.state()["pending"])
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
|
||||
self.assertFalse(self.sync.state()["pending"])
|
||||
self.frame.calls.clear()
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls, []) # off again afterwards
|
||||
|
||||
def test_not_copied_again_after_deleting_it_here(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.sync.tick()
|
||||
for s in self.frame.shots + self.frame.clips:
|
||||
s["saved"] = False # the person deleted the files
|
||||
self.frame.saved.clear()
|
||||
self.frame.exported.clear()
|
||||
self.make().tick() # remembered across restarts too
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_saved_by_hand_counts_as_copied(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
self.sync.tick()
|
||||
self.frame.shots[0]["saved"] = False
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.saved, [])
|
||||
|
||||
def test_remembered_per_headset(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.sync.tick()
|
||||
self.frame.device = "frame-b"
|
||||
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
|
||||
self.sync.tick()
|
||||
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
|
||||
|
||||
def test_clip_still_being_recorded_waits(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.exported, [])
|
||||
self.assertEqual(self.sync.state()["waiting"], 2)
|
||||
self.frame.clips[0]["idle"] = fa.SETTLE + 1
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertEqual(self.sync.state()["waiting"], 1)
|
||||
|
||||
def test_no_headset_no_work(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.device = None
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_waits_for_other_work(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.busy = True
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
self.frame.busy = False
|
||||
self.sync.tick()
|
||||
self.assertEqual(len(self.frame.saved), 1)
|
||||
|
||||
def test_stops_between_items_when_other_work_starts(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
|
||||
export = self.frame.export_clip
|
||||
|
||||
def then_busy(device, clip_id):
|
||||
export(device, clip_id)
|
||||
self.frame.busy = True # an install started meanwhile
|
||||
self.frame.export_clip = then_busy
|
||||
sync = self.make()
|
||||
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(len(self.frame.exported), 1)
|
||||
|
||||
def test_backs_off_while_unreachable(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.down = True
|
||||
waits = [self.sync.tick() for _ in range(6)]
|
||||
self.assertEqual(waits[:4], [120, 240, 480, 900])
|
||||
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
|
||||
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
|
||||
self.frame.down = False
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.frame.down = True
|
||||
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
|
||||
|
||||
def test_pauses_when_this_computer_is_nearly_full(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.free[0] = 10 * 1024**2
|
||||
self.sync.tick()
|
||||
s = self.sync.state()
|
||||
self.assertIn("out of space", s["paused"])
|
||||
self.assertEqual(self.frame.saved, [])
|
||||
self.free[0] = 100 * GB
|
||||
self.frame.calls.clear()
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
|
||||
self.assertTrue(self.make().state()["paused"])
|
||||
self.sync.resume()
|
||||
self.sync.tick()
|
||||
self.assertEqual(len(self.frame.saved), 1)
|
||||
self.assertIsNone(self.sync.state()["paused"])
|
||||
|
||||
def test_pauses_when_a_copy_runs_out_of_space(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
|
||||
self.sync.tick()
|
||||
self.assertIn("ran out of space", self.sync.state()["paused"])
|
||||
self.sync.resume()
|
||||
self.frame.export_error = None
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
|
||||
self.sync.tick()
|
||||
self.assertTrue(self.sync.state()["paused"])
|
||||
|
||||
def test_space_on_the_frame_is_not_a_pause(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
|
||||
self.sync.tick()
|
||||
s = self.sync.state()
|
||||
self.assertIsNone(s["paused"])
|
||||
self.assertIn("Not enough free space on the Frame", s["error"])
|
||||
|
||||
def test_an_item_that_keeps_failing_is_left_alone(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
|
||||
t.start()
|
||||
stop.set()
|
||||
self.sync.update({}) # wakes it
|
||||
t.join(5)
|
||||
self.assertFalse(t.is_alive())
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.auto_sync({"action": "explode"})
|
||||
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
|
||||
self.assertFalse(state["screenshots"])
|
||||
if server.LINK is None:
|
||||
with self.assertRaises(server.Failure):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Backup and recovery kit (ui/frame_backup.py, ui/frame_backup_remote.py) against a folder laid
|
||||
out like the Frame's home. The headset-side script runs for real, locally; only SSH is faked."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_android as android # noqa: E402
|
||||
import frame_backup as backup # noqa: E402
|
||||
import frame_backup_remote as remote # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
STEAM = '.local/share/Steam'
|
||||
USER = STEAM + '/steamapps/compatdata/%s/pfx/drive_c/users/steamuser'
|
||||
SECRETS = ['.steam/steam.token', STEAM + '/config/config.vdf', STEAM + '/config/loginusers.vdf',
|
||||
STEAM + '/ssfn123456', '.ssh/id_ed25519', '.ssh/authorized_keys',
|
||||
USER % 1234 + '/AppData/Roaming/Launcher/session.token',
|
||||
USER % 1234 + '/AppData/Roaming/Launcher/.ssh/id_rsa',
|
||||
'.config/cliproxyapi/secrets.env', '.local/share/keyrings/login.keyring']
|
||||
|
||||
|
||||
def write(home, rel, data=b'x'):
|
||||
path = Path(home) / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data if isinstance(data, bytes) else data.encode())
|
||||
return path
|
||||
|
||||
|
||||
def manifest(home, appid, name):
|
||||
write(home, '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid),
|
||||
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n}\n' % (appid, name))
|
||||
|
||||
|
||||
def frame_home(home):
|
||||
"""A Frame before a reset: one Proton game, a Linux game, settings and secrets."""
|
||||
manifest(home, 1234, 'Test Game')
|
||||
write(home, USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat', b'save one')
|
||||
write(home, USER % 1234 + '/Documents/My Games/Game/profile.sav', b'profile')
|
||||
write(home, USER % 1234 + '/AppData/Local/Temp/junk.tmp', b'junk')
|
||||
write(home, USER % 1234 + '/AppData/Local/Microsoft/Windows/x.db', b'wine')
|
||||
write(home, USER % 999 + '/AppData/Local/Temp/only-cache', b'c') # no saves: no part
|
||||
write(home, STEAM + '/steamapps/compatdata/2800000001/internal/org.example/x', b'android')
|
||||
write(home, STEAM + '/userdata/555/config/localconfig.vdf', b'local')
|
||||
write(home, STEAM + '/userdata/555/config/shortcuts.vdf', b'shortcuts')
|
||||
write(home, STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', b'layout')
|
||||
write(home, STEAM + '/userdata/555/1234/remote/cloud.sav', b'cloud') # Steam Cloud's own
|
||||
write(home, STEAM + '/userdata/555/760/remote/1234/screenshots/a.jpg', b'shot')
|
||||
write(home, '.config/kdeglobals', b'[General]')
|
||||
write(home, '.config/openvr/config/steamvr.vrsettings', b'{}')
|
||||
write(home, '.config/unity3d/Company/LinuxGame/prefs', b'prefs')
|
||||
write(home, '.bashrc', b'# mine')
|
||||
for rel in SECRETS:
|
||||
write(home, rel, b'SECRET')
|
||||
|
||||
|
||||
class Remote(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.home = Path(self.tmp.name)
|
||||
patcher = patch.object(remote, 'HOME', self.home)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def files(self, part):
|
||||
return [rel for rel, _ in remote.part_files(part)[0]]
|
||||
|
||||
def test_plan_picks_saves_and_settings_and_never_secrets(self):
|
||||
frame_home(self.home)
|
||||
plan = remote.plan()
|
||||
ids = [p['id'] for p in plan['parts']]
|
||||
self.assertEqual(ids, ['saves:1234', 'saves:linux', 'settings:steam', 'settings:system'])
|
||||
self.assertEqual(plan['parts'][0]['label'], 'Test Game saves')
|
||||
saves = self.files('saves:1234')
|
||||
self.assertEqual(sorted(saves), [USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat',
|
||||
USER % 1234 + '/Documents/My Games/Game/profile.sav'])
|
||||
steam = self.files('settings:steam')
|
||||
self.assertIn(STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', steam)
|
||||
self.assertIn(STEAM + '/userdata/555/config/localconfig.vdf', steam)
|
||||
self.assertFalse([f for f in steam if '/1234/remote/' in f or '/760/' in f])
|
||||
everything = sum((self.files(i) for i in ids), [])
|
||||
for rel in SECRETS + ['.bashrc']:
|
||||
self.assertNotIn(rel, everything)
|
||||
self.assertFalse([f for f in everything if remote.is_secret(f)])
|
||||
self.assertTrue(any('steam.token' in x for x in plan['excluded']))
|
||||
# The controller layout goes back; localconfig.vdf and shortcuts.vdf are for reference.
|
||||
steam_part = plan['parts'][2]
|
||||
self.assertEqual((steam_part['files'], steam_part['restorable']), (3, 1))
|
||||
|
||||
def test_secret_names(self):
|
||||
for rel in SECRETS + ['a/b/ID_RSA.pub', 'x/kwalletd/k', 'x/cert.PEM']:
|
||||
self.assertTrue(remote.is_secret(rel), rel)
|
||||
for rel in ['saves/game.sav', 'Documents/tokens-collected.txt', 'config/localconfig.vdf']:
|
||||
self.assertFalse(remote.is_secret(rel), rel)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_links_are_never_followed(self):
|
||||
frame_home(self.home)
|
||||
outside = write(self.home, 'elsewhere/private.txt', b'p')
|
||||
base = self.home / (USER % 1234) / 'Documents'
|
||||
os.symlink(str(outside.parent), str(base / 'linked-dir'))
|
||||
os.symlink(str(outside), str(base / 'linked-file'))
|
||||
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
||||
|
||||
def test_big_files_are_listed_as_skipped(self):
|
||||
frame_home(self.home)
|
||||
with patch.object(remote, 'MAX_FILE', 3):
|
||||
files, skipped = remote.part_files('saves:1234')
|
||||
self.assertEqual(files, [])
|
||||
self.assertEqual({s['reason'] for s in skipped}, {'over 2 GiB'})
|
||||
|
||||
def test_pack_holds_exactly_the_planned_files(self):
|
||||
frame_home(self.home)
|
||||
out = io.BytesIO()
|
||||
remote.pack('saves:1234', out)
|
||||
out.seek(0)
|
||||
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
||||
names = sorted(m.name for m in archive)
|
||||
self.assertEqual(names, sorted(self.files('saves:1234')))
|
||||
with self.assertRaises(ValueError):
|
||||
remote.pack('saves:../../etc', io.BytesIO())
|
||||
|
||||
def archive(self, members):
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode='w:gz') as tar:
|
||||
for name, data in members:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
out.seek(0)
|
||||
return out
|
||||
|
||||
def test_check_after_a_reset(self):
|
||||
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
||||
ask = {'parts': [{'id': 'saves:1234', 'files': [save]},
|
||||
{'id': 'settings:steam', 'files': [STEAM + '/userdata/555/241100/remote/controller_config/1/a.vdf']},
|
||||
{'id': 'settings:system', 'files': ['.config/openvr/config/steamvr.vrsettings']},
|
||||
{'id': 'saves:../x', 'files': []}]}
|
||||
states = lambda: [p['status'] for p in remote.check(ask)['parts']] # noqa: E731
|
||||
self.assertEqual(states(), ['not_installed', 'no_account', 'reference', 'unknown'])
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / STEAM / 'userdata/555').mkdir(parents=True)
|
||||
self.assertEqual(states()[:2], ['no_prefix', 'ok'])
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
result = remote.check(ask)['parts'][0]
|
||||
self.assertEqual((result['status'], result['new'], result['replace']), ('ok', 1, 0))
|
||||
write(self.home, save, b'newer')
|
||||
self.assertEqual(remote.check(ask)['parts'][0]['replace'], 1)
|
||||
|
||||
def test_restore_replaces_only_its_files_and_keeps_the_old_ones(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
|
||||
write(self.home, save, b'played since')
|
||||
other = write(self.home, USER % 1234 + '/AppData/Roaming/Studio/Game/other.dat', b'untouched')
|
||||
bashrc = write(self.home, '.bashrc', b'# mine')
|
||||
source = self.archive([(save, b'from backup'), (USER % 1234 + '/Documents/new.sav', b'new'),
|
||||
('.bashrc', b'# replaced?')])
|
||||
result = remote.restore('saves:1234', '20261023-120000', source)
|
||||
self.assertEqual((result['written'], result['replaced']), (2, 1))
|
||||
self.assertEqual(result['held'], {'reference': 1})
|
||||
self.assertEqual((self.home / save).read_bytes(), b'from backup')
|
||||
self.assertEqual(other.read_bytes(), b'untouched')
|
||||
self.assertEqual(bashrc.read_bytes(), b'# mine')
|
||||
kept = self.home / remote.PREVIOUS / '20261023-120000' / save
|
||||
self.assertEqual(kept.read_bytes(), b'played since')
|
||||
self.assertFalse([p for p in (self.home / save).parent.iterdir() if p.name.endswith('.part')])
|
||||
|
||||
def test_unsafe_archives_write_nothing(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
good = (USER % 1234 + '/Documents/a.sav', b'a')
|
||||
for bad in ('../escape', '/etc/passwd', USER % 1234 + '/Documents/../../../../../../x',
|
||||
USER % 1234 + '/Documents/a.sav'):
|
||||
with self.assertRaises(ValueError, msg=bad):
|
||||
remote.restore('saves:1234', '20261023-120000', self.archive([good, (bad, b'evil')]))
|
||||
self.assertFalse((self.home / good[0]).exists(), bad)
|
||||
link = io.BytesIO()
|
||||
with tarfile.open(fileobj=link, mode='w:gz') as tar:
|
||||
info = tarfile.TarInfo(USER % 1234 + '/Documents/l')
|
||||
info.type, info.linkname = tarfile.SYMTYPE, '/etc/passwd'
|
||||
tar.addfile(info)
|
||||
link.seek(0)
|
||||
with self.assertRaises(ValueError):
|
||||
remote.restore('saves:1234', '20261023-120000', link)
|
||||
with self.assertRaises(ValueError):
|
||||
remote.restore('saves:1234', '../stamp', self.archive([good]))
|
||||
|
||||
def test_restore_refuses_secrets_and_other_games(self):
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True)
|
||||
result = remote.restore('saves:1234', '20261023-120000', self.archive([
|
||||
(USER % 1234 + '/AppData/Roaming/L/auth.token', b't'), (USER % 5678 + '/Documents/x.sav', b'x')]))
|
||||
self.assertEqual(result['written'], 0)
|
||||
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
|
||||
|
||||
def test_session_reads_the_mode(self):
|
||||
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
|
||||
running = set()
|
||||
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
|
||||
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
|
||||
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
|
||||
state = remote.session()
|
||||
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
|
||||
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
|
||||
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
running.clear()
|
||||
running.add('start-gamescope-session')
|
||||
self.assertEqual(remote.session()['mode'], 'game')
|
||||
|
||||
|
||||
def fake_ssh(home, os_release):
|
||||
"""Runs the headset-side command locally, as the Frame's python3 would."""
|
||||
def run(command, src=None, dst=None, timeout=None):
|
||||
argv = shlex.split(command)
|
||||
assert argv[:2] == ['python3', '-c'], argv
|
||||
data = src if isinstance(src, bytes) else src.read() if src is not None else b''
|
||||
env = dict(os.environ, FRAME_BACKUP_HOME=str(home), FRAME_BACKUP_OS_RELEASE=str(os_release))
|
||||
r = subprocess.run([sys.executable, *argv[1:]], input=data, capture_output=True, env=env)
|
||||
if r.returncode:
|
||||
raise backup.BackupError(r.stderr.decode(errors='replace'))
|
||||
if dst is not None:
|
||||
dst.write(r.stdout)
|
||||
return None
|
||||
return r.stdout
|
||||
return run
|
||||
|
||||
|
||||
class Local(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
base = Path(self.tmp.name)
|
||||
self.home, self.store = base / 'frame', base / 'backups'
|
||||
self.release = write(base, 'os-release', 'ID=steamos\nVERSION_ID=0.4.1\nBUILD_ID=20260925.6191901\n')
|
||||
frame_home(self.home)
|
||||
for p in (patch.dict(os.environ, FRAME_BACKUP_DIR=str(self.store)),
|
||||
patch.object(backup, '_ssh', side_effect=fake_ssh(self.home, self.release))):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def android_backup(self, package, destination):
|
||||
write(Path(destination).parent, Path(destination).name, b'android archive ' + package.encode())
|
||||
return {'package': package, 'instance': android.instance_id(package), 'files': 3, 'bytes': 42,
|
||||
'path': str(destination), 'sha256': backup._sha256(destination)}
|
||||
|
||||
def make(self):
|
||||
apps = [{'package': 'org.example.open', 'label': 'Open app', 'running': True},
|
||||
{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
||||
with patch.object(android, 'list_apps', return_value=apps), \
|
||||
patch.object(android, 'backup_data', side_effect=self.android_backup) as data, \
|
||||
patch.object(backup.time, 'strftime', side_effect=lambda f: '2026-10-23-120000' if f == '%Y-%m-%d-%H%M%S'
|
||||
else '2026-10-23T12:00:00+0100'):
|
||||
result = backup.backup(headset='My Frame')
|
||||
data.assert_called_once()
|
||||
return result
|
||||
|
||||
def test_backup_writes_a_manifest_with_checksums(self):
|
||||
result = self.make()
|
||||
folder = backup.backup_path('2026-10-23-120000') # resolved: macOS's temp folder is a link
|
||||
self.assertEqual(result['path'], str(folder))
|
||||
self.assertEqual(sorted(p.name for p in self.store.iterdir()), ['2026-10-23-120000']) # no .partial left
|
||||
m = json.loads((folder / 'manifest.json').read_text())
|
||||
self.assertEqual(m['headset'], {'name': 'My Frame', 'build': '20260925.6191901', 'version': '0.4.1'})
|
||||
self.assertEqual([p['id'] for p in m['parts']],
|
||||
['saves:1234', 'saves:linux', 'settings:steam', 'settings:system', 'android:org.example.game'])
|
||||
for part in m['parts']:
|
||||
self.assertEqual(backup._sha256(folder / part['archive']), part['sha256'])
|
||||
saves = m['parts'][0]
|
||||
self.assertEqual(saves['archive'], 'saves/saves-1234.tar.gz')
|
||||
self.assertEqual(sum(f['bytes'] for f in saves['files']), saves['bytes'])
|
||||
self.assertEqual(saves['files'][0]['sha256'], __import__('hashlib').sha256(b'save one').hexdigest())
|
||||
self.assertEqual([s['id'] for s in m['skipped']], ['android:org.example.open'])
|
||||
for part in m['parts'][:-1]:
|
||||
with tarfile.open(str(folder / part['archive'])) as tar:
|
||||
for member in tar:
|
||||
self.assertFalse(remote.is_secret(member.name), member.name)
|
||||
self.assertNotEqual(tar.extractfile(member).read(), b'SECRET', member.name)
|
||||
|
||||
def test_a_failed_backup_leaves_nothing_behind(self):
|
||||
with patch.object(backup, 'inspect_archive', side_effect=backup.BackupError('cut off')), \
|
||||
patch.object(android, 'list_apps', return_value=[]):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.backup(kinds=['saves'])
|
||||
self.assertEqual(list(self.store.iterdir()), [])
|
||||
|
||||
def test_backup_names_cannot_leave_the_folder(self):
|
||||
for name in ('../2026-10-23-120000', '2026-10-23-120000/..', '..', '/etc', 'C:\\x', '',
|
||||
'2026-10-23-120000/../../x', '2026-10-23-12000', None, 5):
|
||||
with self.assertRaises(backup.BackupError, msg=repr(name)):
|
||||
backup.backup_path(name)
|
||||
self.assertEqual(backup.backup_path('2026-10-23-120000'), (self.store / '2026-10-23-120000').resolve())
|
||||
|
||||
def test_manifest_entries_cannot_point_outside_the_backup(self):
|
||||
self.make()
|
||||
path = self.store / '2026-10-23-120000' / 'manifest.json'
|
||||
m = json.loads(path.read_text())
|
||||
for archive in ('../../secret.tar.gz', '/etc/passwd', 'saves/saves-5678.tar.gz'):
|
||||
m['parts'][0]['archive'] = archive
|
||||
path.write_text(json.dumps(m))
|
||||
with self.assertRaises(backup.BackupError, msg=archive):
|
||||
backup.read_manifest('2026-10-23-120000')
|
||||
|
||||
def reset(self):
|
||||
"""A factory reset: the home folder is empty again."""
|
||||
import shutil
|
||||
shutil.rmtree(str(self.home))
|
||||
self.home.mkdir()
|
||||
|
||||
def test_restore_after_a_reset_skips_what_is_not_back_yet(self):
|
||||
self.make()
|
||||
self.reset()
|
||||
manifest(self.home, 1234, 'Test Game')
|
||||
(self.home / (USER % 1234)).mkdir(parents=True) # started once
|
||||
with patch.object(android, 'list_apps', return_value=[]):
|
||||
plan = {p['id']: p for p in backup.restore_plan('2026-10-23-120000')['parts']}
|
||||
self.assertEqual(plan['saves:1234']['status'], 'ok')
|
||||
self.assertEqual(plan['settings:steam']['status'], 'no_account')
|
||||
self.assertEqual(plan['android:org.example.game']['status'], 'not_installed')
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.restore('2026-10-23-120000', ['saves:1234']) # not confirmed
|
||||
self.assertFalse((self.home / (USER % 1234) / 'AppData').exists())
|
||||
result = backup.restore('2026-10-23-120000', ['saves:1234', 'settings:steam', 'android:org.example.game'],
|
||||
confirmed=True)
|
||||
self.assertEqual([r['id'] for r in result['restored']], ['saves:1234'])
|
||||
self.assertEqual({s['id'] for s in result['skipped']}, {'settings:steam', 'android:org.example.game'})
|
||||
self.assertEqual((self.home / (USER % 1234) / 'AppData/Roaming/Studio/Game/save1.dat').read_bytes(), b'save one')
|
||||
|
||||
def test_android_data_goes_back_through_the_existing_helper(self):
|
||||
self.make()
|
||||
apps = [{'package': 'org.example.game', 'label': 'Game', 'running': False}]
|
||||
with patch.object(android, 'list_apps', return_value=apps), \
|
||||
patch.object(android, 'restore_data', return_value={'restored': True}) as restore:
|
||||
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
||||
restore.assert_called_once_with('org.example.game', str(backup.backup_path('2026-10-23-120000') /
|
||||
'android' / 'org.example.game.tar.gz'))
|
||||
self.assertEqual(len(result['restored']), 1)
|
||||
apps[0]['running'] = True
|
||||
with patch.object(android, 'list_apps', return_value=apps), patch.object(android, 'restore_data') as restore:
|
||||
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
|
||||
restore.assert_not_called()
|
||||
self.assertEqual(result['skipped'][0]['reason'], 'Close the app first')
|
||||
|
||||
def test_a_damaged_archive_is_skipped(self):
|
||||
self.make()
|
||||
with open(str(self.store / '2026-10-23-120000/saves/saves-1234.tar.gz'), 'ab') as f:
|
||||
f.write(b'tampered')
|
||||
with patch.object(android, 'list_apps', return_value=[]):
|
||||
result = backup.restore('2026-10-23-120000', ['saves:1234'], confirmed=True)
|
||||
self.assertEqual(result['restored'], [])
|
||||
self.assertIn('checksum', result['skipped'][0]['reason'])
|
||||
|
||||
def test_list_backups(self):
|
||||
self.make()
|
||||
(self.store / '.2026-10-23-130000.partial').mkdir()
|
||||
(self.store / 'notes').mkdir()
|
||||
listed = backup.list_backups()['backups']
|
||||
self.assertEqual([b['name'] for b in listed], ['2026-10-23-120000'])
|
||||
self.assertEqual(listed[0]['build'], '20260925.6191901')
|
||||
|
||||
|
||||
class Ssh(unittest.TestCase):
|
||||
def test_bytes_go_as_input_and_files_as_stdin(self):
|
||||
done = subprocess.CompletedProcess([], 0, b'{}', b'')
|
||||
with patch.object(backup.frame_host, 'run_ssh', return_value=done) as run:
|
||||
backup._ssh('true', src=b'{"parts": []}')
|
||||
self.assertEqual(run.call_args.kwargs['input'], b'{"parts": []}')
|
||||
self.assertNotIn('stdin', run.call_args.kwargs)
|
||||
with tempfile.TemporaryFile() as f:
|
||||
backup._ssh('true', src=f)
|
||||
self.assertIs(run.call_args.kwargs['stdin'], f)
|
||||
backup._ssh('true')
|
||||
self.assertIs(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
|
||||
self.assertEqual(run.call_args.args[0][-1], 'true')
|
||||
with patch.object(backup.frame_host, 'run_ssh',
|
||||
return_value=subprocess.CompletedProcess([], 1, b'', b'Permission denied')):
|
||||
with self.assertRaisesRegex(backup.BackupError, 'Permission denied'):
|
||||
backup._ssh('true')
|
||||
|
||||
|
||||
class GameMode(unittest.TestCase):
|
||||
def test_command_is_only_ever_the_session_switch(self):
|
||||
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
|
||||
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
|
||||
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.session_command(bad)
|
||||
|
||||
def test_switch_needs_confirmation_and_desktop_mode(self):
|
||||
with patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.return_to_game_mode()
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'game', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'unknown', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': 'steamos-session-select'}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.assert_called_once_with('steamos-session-select gamescope', timeout=60)
|
||||
ssh.side_effect = backup.BackupError('the headset took too long to answer') # the session restarted
|
||||
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
|
||||
ssh.side_effect = backup.BackupError('Interactive authentication required')
|
||||
with self.assertRaisesRegex(backup.BackupError, 'manual steps'):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': None}), \
|
||||
patch.object(backup, '_ssh') as ssh:
|
||||
with self.assertRaises(backup.BackupError):
|
||||
backup.return_to_game_mode(confirmed=True)
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
class Endpoint(unittest.TestCase):
|
||||
def test_guards_run_before_the_headset(self):
|
||||
with patch.object(backup, '_ssh') as ssh, patch.object(server, 'start_job') as job:
|
||||
for body in ({'action': 'plan', 'name': '../x'}, {'action': 'open', 'name': '..'},
|
||||
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:1234']},
|
||||
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:x'], 'confirm': True},
|
||||
{'action': 'gamemode'}, {'action': 'backup', 'kinds': ['ssh-keys']}, {'action': 'nope'}):
|
||||
with self.assertRaises(server.Failure, msg=body) as caught:
|
||||
server.backup_action(body)
|
||||
self.assertEqual(caught.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
job.assert_not_called()
|
||||
with patch.object(server, 'LOCAL', True):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.backup_action({'action': 'list'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,241 @@
|
||||
"""The clipboard both ways: the headset's read script, the server's answers, and the
|
||||
writer for this computer's clipboard on each platform. No headset: SSH and every
|
||||
clipboard program are mocked, and the read script runs against stub pgrep and qdbus6."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_host # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
TEXT = "héllo, 世界 🎧\nsecond line\n\n" # accents, CJK, an emoji outside the BMP, trailing newlines
|
||||
|
||||
|
||||
def b64(text):
|
||||
return base64.b64encode(text.encode()).decode()
|
||||
|
||||
|
||||
class ReadCommand(unittest.TestCase):
|
||||
def test_reads_klipper_on_the_desktop_bus(self):
|
||||
self.assertIn("org.kde.klipper.klipper.getClipboardContents", server.COPY)
|
||||
self.assertIn("DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper", server.COPY)
|
||||
self.assertIn(server.DESKTOP_CLOSED, server.COPY)
|
||||
self.assertIn(server.CLIPBOARD_TOO_BIG, server.COPY)
|
||||
self.assertIn(f"-gt {server.CLIPBOARD_LIMIT + 1} ]", server.COPY) # the limit, plus qdbus6's newline
|
||||
self.assertIn("base64 -w 0", server.COPY)
|
||||
|
||||
def test_command_carries_the_script_intact(self):
|
||||
encoded = server.COPY_CMD.split("echo ", 1)[1].split(" |", 1)[0]
|
||||
self.assertEqual(base64.b64decode(encoded).decode(), server.COPY)
|
||||
self.assertNotIn("'", server.COPY_CMD)
|
||||
|
||||
def test_send_still_uses_the_same_bus(self):
|
||||
self.assertIn(server.DESKTOP_BUS, server.PASTE)
|
||||
self.assertIn(server.DESKTOP_BUS, server.COPY)
|
||||
self.assertIn("setClipboardContents", server.PASTE)
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("bash") and shutil.which("base64"),
|
||||
"the script reads /proc, as on the Frame")
|
||||
class ReadScript(unittest.TestCase):
|
||||
"""server.COPY for real, with plasmashell's pid and Klipper stubbed."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp(prefix="frame-clip-"))
|
||||
self.addCleanup(shutil.rmtree, self.dir, True)
|
||||
self.clip = self.dir / "clip"
|
||||
# A process whose environment holds the desktop's bus address, standing in for plasmashell.
|
||||
self.plasma = subprocess.Popen(["sleep", "60"], env={**os.environ, "DBUS_SESSION_BUS_ADDRESS": "unix:path=/fake"})
|
||||
self.addCleanup(self.plasma.wait)
|
||||
self.addCleanup(self.plasma.kill)
|
||||
(self.dir / "pgrep").write_text(f'#!/bin/sh\n[ -e "{self.dir}/closed" ] || echo {self.plasma.pid}\n')
|
||||
(self.dir / "qdbus6").write_text(
|
||||
'#!/bin/sh\n[ "$DBUS_SESSION_BUS_ADDRESS" = unix:path=/fake ] || exit 1\n'
|
||||
f'[ "$3" = org.kde.klipper.klipper.getClipboardContents ] || exit 1\ncat "{self.clip}"; echo\n')
|
||||
for name in ("pgrep", "qdbus6"):
|
||||
(self.dir / name).chmod(0o755)
|
||||
|
||||
def run_script(self):
|
||||
def local_ssh(remote, **kw):
|
||||
r = subprocess.run(["bash", "-c", server.COPY], capture_output=True, text=True,
|
||||
env={**os.environ, "PATH": f"{self.dir}:{os.environ['PATH']}"})
|
||||
if r.returncode:
|
||||
raise server.Failure(r.stderr.strip())
|
||||
return r.stdout
|
||||
with mock.patch.object(server, "ssh", local_ssh):
|
||||
return server.headset_clipboard()
|
||||
|
||||
def test_unicode_and_newlines_come_back_exactly(self):
|
||||
self.clip.write_bytes(TEXT.encode())
|
||||
self.assertEqual(self.run_script(), TEXT)
|
||||
|
||||
def test_empty(self):
|
||||
self.clip.write_bytes(b"")
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn("empty", str(cm.exception))
|
||||
|
||||
def test_too_big_is_refused_on_the_headset(self):
|
||||
self.clip.write_bytes(b"x" * (server.CLIPBOARD_LIMIT + 1))
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 413)
|
||||
self.assertIn("1 MB", str(cm.exception))
|
||||
self.clip.write_bytes(b"x" * server.CLIPBOARD_LIMIT)
|
||||
self.assertEqual(len(self.run_script()), server.CLIPBOARD_LIMIT)
|
||||
|
||||
def test_desktop_closed(self):
|
||||
(self.dir / "closed").touch()
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.run_script()
|
||||
self.assertEqual(cm.exception.status, 409)
|
||||
|
||||
|
||||
class HeadsetClipboard(unittest.TestCase):
|
||||
def read(self, out=None, error=None):
|
||||
def fake_ssh(remote, **kw):
|
||||
self.assertEqual(remote, server.COPY_CMD)
|
||||
self.assertNotIn("stdin", kw)
|
||||
if error:
|
||||
raise server.Failure(error)
|
||||
return out
|
||||
with mock.patch.object(server, "ssh", fake_ssh):
|
||||
return server.headset_clipboard()
|
||||
|
||||
def test_drops_only_qdbus6s_newline(self):
|
||||
self.assertEqual(self.read(b64(TEXT + "\n") + "\n"), TEXT)
|
||||
self.assertEqual(self.read(b64("one line\n")), "one line")
|
||||
|
||||
def test_empty_and_images_say_so(self):
|
||||
for out, words in ((b64("\n"), "empty"), ("", "empty"), (b64("▨ 640x480 32bpp\n"), "image")):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(out)
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn(words, str(cm.exception))
|
||||
|
||||
def test_unreadable_output_is_a_fault(self):
|
||||
for out in ("not base64!", base64.b64encode(b"\xff\xfe\n").decode()):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(out)
|
||||
self.assertEqual(cm.exception.status, 502)
|
||||
|
||||
def test_failures(self):
|
||||
for error, status in (("The headset's desktop isn't open (plasmashell is not running).", 409),
|
||||
("The headset's clipboard holds too much to copy (over 1 MB).", 413),
|
||||
("Klipper call failed (bus: none)", 502)):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
self.read(error=error)
|
||||
self.assertEqual(cm.exception.status, status, error)
|
||||
|
||||
|
||||
class ClipboardRequests(unittest.TestCase):
|
||||
def test_read_returns_the_text_for_the_page(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT):
|
||||
out = server.clipboard({"read": True})
|
||||
self.assertEqual(out["text"], TEXT)
|
||||
self.assertNotIn(TEXT, out["message"])
|
||||
|
||||
def test_to_computer_sets_this_computers_clipboard(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
|
||||
mock.patch.object(frame_host, "set_clipboard_text") as put:
|
||||
out = server.clipboard({"toComputer": True})
|
||||
put.assert_called_once_with(TEXT)
|
||||
self.assertNotIn("text", out)
|
||||
self.assertEqual(out["message"], f"Copied {len(TEXT)} characters from the headset to this computer's clipboard")
|
||||
|
||||
def test_to_computer_without_a_clipboard_program(self):
|
||||
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
|
||||
mock.patch.object(frame_host, "set_clipboard_text", side_effect=frame_host.HostError("Can't set the clipboard")):
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.clipboard({"toComputer": True})
|
||||
self.assertEqual(cm.exception.status, 500)
|
||||
|
||||
def test_to_computer_refused_when_the_server_runs_on_the_headset(self):
|
||||
with mock.patch.object(server, "LOCAL", True), mock.patch.object(server, "headset_clipboard") as read:
|
||||
with self.assertRaises(server.Failure) as cm:
|
||||
server.clipboard({"toComputer": True})
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
read.assert_not_called()
|
||||
|
||||
def test_flags_must_be_true(self):
|
||||
# Anything else is a send, and an empty one is refused before SSH.
|
||||
with mock.patch.object(server, "ssh") as ssh:
|
||||
for body in ({"read": "yes"}, {"toComputer": 1}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.clipboard(body)
|
||||
ssh.assert_not_called()
|
||||
|
||||
|
||||
class SetClipboardText(unittest.TestCase):
|
||||
def put(self, platform, have, text=TEXT, fail=()):
|
||||
runs = []
|
||||
|
||||
def run(cmd, **kw):
|
||||
runs.append((cmd, kw))
|
||||
if cmd[0] in fail:
|
||||
if fail[cmd[0]] == "timeout":
|
||||
raise subprocess.TimeoutExpired(cmd, 10)
|
||||
return subprocess.CompletedProcess(cmd, 1)
|
||||
return subprocess.CompletedProcess(cmd, 0)
|
||||
flags = {"MAC": platform == "mac", "WINDOWS": platform == "windows", "LINUX": platform == "linux"}
|
||||
with mock.patch.multiple(frame_host, **flags), \
|
||||
mock.patch.object(frame_host.shutil, "which", lambda name: f"/bin/{name}" if name in have else None), \
|
||||
mock.patch.object(frame_host.subprocess, "run", run):
|
||||
frame_host.set_clipboard_text(text)
|
||||
for _, kw in runs:
|
||||
# Nothing captured or logged, and no pipes left for a clipboard daemon to hold open.
|
||||
self.assertEqual((kw["stdout"], kw["stderr"]), (subprocess.DEVNULL, subprocess.DEVNULL))
|
||||
return runs
|
||||
|
||||
def test_mac_pbcopy_in_utf8(self):
|
||||
[(cmd, kw)] = self.put("mac", {"pbcopy"})
|
||||
self.assertEqual(cmd, ["pbcopy"])
|
||||
self.assertEqual(kw["input"], TEXT.encode("utf-8"))
|
||||
self.assertEqual(kw["env"]["LC_CTYPE"], "UTF-8")
|
||||
|
||||
def test_windows_powershell_gets_base64_utf8(self):
|
||||
[(cmd, kw)] = self.put("windows", {"powershell.exe", "clip.exe"})
|
||||
self.assertEqual(cmd[0], "powershell.exe")
|
||||
self.assertIn("Set-Clipboard", cmd[-1])
|
||||
self.assertIn("FromBase64String", cmd[-1])
|
||||
self.assertEqual(base64.b64decode(kw["input"]).decode("utf-8"), TEXT)
|
||||
|
||||
def test_windows_clip_fallback_gets_utf16_with_a_bom(self):
|
||||
runs = self.put("windows", {"powershell.exe", "clip.exe"}, fail={"powershell.exe": 1})
|
||||
self.assertEqual([c[0] for c, _ in runs], ["powershell.exe", "clip.exe"])
|
||||
data = runs[1][1]["input"]
|
||||
self.assertEqual(data[:2], b"\xff\xfe")
|
||||
self.assertEqual(data[2:].decode("utf-16-le"), TEXT)
|
||||
|
||||
def test_linux_tries_wayland_then_x11(self):
|
||||
[(cmd, kw)] = self.put("linux", {"wl-copy", "xclip", "xsel"})
|
||||
self.assertEqual(cmd, ["wl-copy"])
|
||||
self.assertEqual(kw["input"], TEXT.encode())
|
||||
runs = self.put("linux", {"wl-copy", "xclip", "xsel"}, fail={"wl-copy": 1, "xclip": "timeout"})
|
||||
self.assertEqual([c for c, _ in runs], [["wl-copy"], ["xclip", "-selection", "clipboard", "-in"],
|
||||
["xsel", "--clipboard", "--input"]])
|
||||
[(cmd, _)] = self.put("linux", {"xsel"})
|
||||
self.assertEqual(cmd[0], "xsel")
|
||||
|
||||
def test_nothing_to_set_it_with(self):
|
||||
with self.assertRaises(frame_host.HostError) as cm:
|
||||
self.put("linux", set())
|
||||
self.assertIn("wl-clipboard", str(cm.exception))
|
||||
with self.assertRaises(frame_host.HostError):
|
||||
self.put("linux", {"wl-copy"}, fail={"wl-copy": 1})
|
||||
with self.assertRaises(frame_host.HostError):
|
||||
self.put("mac", set())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Recordings of the headset view and Steam clip exports (ui/frame_clips.py), without a headset.
|
||||
|
||||
Steam's recordings are fixture trees in the layout frame_clips.py describes. The
|
||||
recording's shell runs for real under bash with a stand-in ffmpeg, so Stop, the time
|
||||
limit and the clean-up are the real script's behaviour. With ffmpeg installed here, a
|
||||
65-second DASH recording is made in Steam's file naming and exported for real.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_clips as clips # noqa: E402
|
||||
|
||||
POSIX = os.name != "nt" and shutil.which("bash")
|
||||
|
||||
|
||||
def write(path, data=b"x"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def session(folder, streams=(0, 1), chunks=3, mpd=None, first=1, pad=5):
|
||||
"""A session folder as Steam writes it: init + numbered chunks per stream."""
|
||||
for n in streams:
|
||||
write(folder / f"init-stream{n}.m4s", f"init{n};".encode())
|
||||
for c in range(first, first + chunks):
|
||||
write(folder / f"chunk-stream{n}-{c:0{pad}d}.m4s", f"s{n}c{c};".encode())
|
||||
if mpd is not None:
|
||||
write(folder / "session.mpd", mpd.encode())
|
||||
return folder
|
||||
|
||||
|
||||
class Ids(unittest.TestCase):
|
||||
def test_good_ids(self):
|
||||
for cid in ("123/video/bg_620_20261020_201950",
|
||||
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
|
||||
"9/clips/a/b/c/d"):
|
||||
self.assertEqual(clips.parse_id(cid)[0], cid.split("/")[0])
|
||||
self.assertEqual(clips.export_name("123/clips/clip_620_20261020_202134/video/bg_620_1"),
|
||||
"clip_620_20261020_202134.mp4")
|
||||
|
||||
def test_no_traversal_or_shell_from_ids(self):
|
||||
for cid in ("", None, 5, "123", "123/video", "123/other/bg", "abc/video/bg", "/123/video/bg",
|
||||
"123/video/..", "123/video/.", "123/video/bg/../../../../.ssh", "123/video/.hidden",
|
||||
"123/video/bg;rm -rf ~", "123/video/bg$(id)", "123/video/bg name", "123/video/bg\n",
|
||||
"123/video/%2e%2e", "123\\video\\bg", "123/video//bg", "123/clips/a/b/c/d/e",
|
||||
"123/video/" + "a" * 101, "1234567890123/video/bg"):
|
||||
with self.subTest(cid=cid), self.assertRaises(ValueError):
|
||||
clips.parse_id(cid)
|
||||
with self.subTest(cid=cid), self.assertRaises(ValueError):
|
||||
clips.export_name(cid)
|
||||
|
||||
def test_export_paths_checked_before_delete(self):
|
||||
good = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
self.assertEqual(clips.remove_command(good), f'rm -rf "$HOME"/{clips.WORK}/export-0123456789abcdef')
|
||||
for bad in (None, "", "/etc/passwd", f"{clips.WORK}/export-0123456789abcdef/../../x.mp4",
|
||||
f"{clips.WORK}/export-xyz/clip.mp4", f"{clips.WORK}/../export-0123456789abcdef/a.mp4",
|
||||
f"{clips.WORK}/export-0123456789abcdef/a.mp4; rm -rf ~", ".ssh/export-0123456789abcdef/a.mp4"):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
clips.remove_command(bad)
|
||||
|
||||
def test_recording_names_checked(self):
|
||||
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
|
||||
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
fn(bad)
|
||||
|
||||
|
||||
class Sessions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
def test_chunks_in_number_order_whatever_the_padding(self):
|
||||
s = self.dir / "s"
|
||||
write(s / "init-stream0.m4s")
|
||||
for c in (10, 2, 1, 100, 9):
|
||||
write(s / f"chunk-stream0-{c}.m4s")
|
||||
write(s / "chunk-stream0-00003.m4s")
|
||||
got = clips.read_session(s)
|
||||
self.assertEqual([os.path.basename(p) for p in got[0]],
|
||||
["init-stream0.m4s", "chunk-stream0-1.m4s", "chunk-stream0-2.m4s", "chunk-stream0-00003.m4s",
|
||||
"chunk-stream0-9.m4s", "chunk-stream0-10.m4s", "chunk-stream0-100.m4s"])
|
||||
self.assertEqual(clips.chunk_gaps(got[0]), 100 - 6)
|
||||
|
||||
def test_unplayable_streams_and_strangers_left_out(self):
|
||||
s = self.dir / "s"
|
||||
session(s, streams=(0,), chunks=2)
|
||||
write(s / "init-stream1.m4s") # no chunks
|
||||
write(s / "chunk-stream2-00001.m4s") # no init
|
||||
write(s / "chunk-stream0-00001.m4s.part") # not a chunk
|
||||
write(s / "thumbnail.jpg")
|
||||
(s / "chunk-stream0-00009.m4s").mkdir() # a folder, not a file
|
||||
if POSIX:
|
||||
os.symlink("/etc/passwd", s / "chunk-stream0-00005.m4s") # links aren't followed
|
||||
got = clips.read_session(s)
|
||||
self.assertEqual(list(got), [0])
|
||||
self.assertEqual(len(got[0]), 3)
|
||||
|
||||
def test_manifest_duration(self):
|
||||
for text, want in (('mediaPresentationDuration="PT1M23.4S"', 83.4), ('mediaPresentationDuration="PT2H"', 7200),
|
||||
('mediaPresentationDuration="PT0H3M0.000S"', 180), ('mediaPresentationDuration="P1DT1S"', 86401),
|
||||
('mediaPresentationDuration="PT"', None), ('nothing here', None), (None, None)):
|
||||
self.assertEqual(clips.mpd_duration(text), want, text)
|
||||
|
||||
def test_listing_a_fixture_tree(self):
|
||||
ud = self.dir / "userdata"
|
||||
gr = ud / "123" / "gamerecordings"
|
||||
session(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950", chunks=40,
|
||||
mpd='<MPD mediaPresentationDuration="PT1M20.0S">')
|
||||
write(gr / "clips" / "clip_620_20261020_202134" / "thumbnail.jpg")
|
||||
session(gr / "video" / "bg_1172470_20261021_090000", streams=(0,), chunks=2)
|
||||
session(gr / "video" / "odd-name", chunks=1) # no stamp: time from the files
|
||||
(gr / "video" / "empty").mkdir()
|
||||
session(gr / "video" / ".hidden", chunks=1) # not a name an id can carry
|
||||
session(gr / "timelines" / "x", chunks=1) # not clips/ or video/
|
||||
session(ud / "notanaccount" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
|
||||
session(gr / "video" / "a" / "b" / "c" / "d" / "e", chunks=1) # deeper than ids go
|
||||
if POSIX:
|
||||
os.symlink(gr, gr / "video" / "loop") # links aren't followed
|
||||
for f in (gr / "video" / "odd-name").iterdir():
|
||||
os.utime(f, (1e9, 1e9)) # 2001: older than the others
|
||||
got = clips.list_recordings(ud)
|
||||
self.assertEqual([c["id"] for c in got], ["123/video/bg_1172470_20261021_090000",
|
||||
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
|
||||
"123/video/odd-name"]) # newest first
|
||||
self.assertEqual(got[-1]["time"], 1e9)
|
||||
by = {c["id"]: c for c in got}
|
||||
clip = by["123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"]
|
||||
self.assertEqual((clip["kind"], clip["appid"], clip["streams"], clip["chunks"], clip["gaps"], clip["duration"]),
|
||||
("clip", "620", 2, 80, 0, 80.0))
|
||||
self.assertEqual(clip["file"], "clip_620_20261020_202134.mp4")
|
||||
self.assertEqual(clip["time"], time.mktime((2026, 10, 20, 20, 21, 34, 0, 0, -1)))
|
||||
self.assertEqual(clip["size"], sum(f.stat().st_size for f in
|
||||
(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950").iterdir()
|
||||
if f.suffix == ".m4s"))
|
||||
bg = by["123/video/bg_1172470_20261021_090000"]
|
||||
self.assertEqual((bg["kind"], bg["appid"], bg["streams"], bg["duration"]), ("background", "1172470", 1, None))
|
||||
self.assertIsNone(by["123/video/odd-name"]["appid"])
|
||||
# How long since Steam last wrote to it: auto-sync waits for a clip still being recorded.
|
||||
self.assertLess(clip["idle"], 60)
|
||||
self.assertGreater(by["123/video/odd-name"]["idle"], 10**8)
|
||||
self.assertEqual(clips.list_recordings(self.dir / "missing"), [])
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
|
||||
{"id": "1/video/bg_620_20261020_201950"}]}
|
||||
got = clips.list_clips(frame, folder=here)
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
|
||||
class Export(unittest.TestCase):
|
||||
"""The Frame's half of an export, against a fixture tree, with ffmpeg stood in."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.ud = self.dir / "home" / clips.STEAM_USERDATA
|
||||
self.home = self.dir / "home"
|
||||
self.cid = "123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"
|
||||
session(self.ud / "123" / "gamerecordings" / "clips" / "clip_620_20261020_202134" / "video" /
|
||||
"bg_620_20261020_201950", chunks=12)
|
||||
|
||||
def fake_ffmpeg(self, seen, rc=0):
|
||||
def run(cmd, **kw):
|
||||
ins = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-i"]
|
||||
seen.append({"cmd": cmd, "inputs": {Path(i).name: Path(i).read_bytes() for i in ins}})
|
||||
if rc == 0:
|
||||
Path(cmd[-1]).write_bytes(b"mp4")
|
||||
return subprocess.CompletedProcess(cmd, rc, "", "Invalid data found" if rc else "")
|
||||
return run
|
||||
|
||||
def test_streams_stitched_in_order_and_copied_not_reencoded(self):
|
||||
seen = []
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg(seen)):
|
||||
got = clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertRegex(got["path"], clips.EXPORT_PATH)
|
||||
self.assertTrue(got["path"].endswith("/clip_620_20261020_202134.mp4"))
|
||||
self.assertEqual(got["size"], 3)
|
||||
cmd = seen[0]["cmd"]
|
||||
self.assertEqual(cmd[cmd.index("-c") + 1], "copy")
|
||||
self.assertEqual([cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"], ["0", "1"])
|
||||
for n in (0, 1):
|
||||
self.assertEqual(seen[0]["inputs"][f"stream{n}.mp4"],
|
||||
f"init{n};".encode() + b"".join(f"s{n}c{c};".encode() for c in range(1, 13)))
|
||||
out = self.home / got["path"]
|
||||
self.assertEqual(sorted(p.name for p in out.parent.iterdir()), [out.name]) # stream files gone
|
||||
|
||||
def test_failed_join_leaves_nothing_behind(self):
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([], rc=1)):
|
||||
with self.assertRaisesRegex(clips.ClipError, "Invalid data"):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertEqual(list((self.home / clips.WORK).glob("export-*")), [])
|
||||
|
||||
def test_bad_or_missing_ids(self):
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export(self.ud, "123/video/../../../../etc", home=self.home)
|
||||
with self.assertRaises(ClipErrorOrOSError):
|
||||
clips.export(self.ud, "123/video/nothing_here", home=self.home)
|
||||
|
||||
@unittest.skipUnless(POSIX, "symlinks")
|
||||
def test_a_linked_account_cannot_reach_outside_userdata(self):
|
||||
outside = session(self.dir / "elsewhere" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
|
||||
os.symlink(self.dir / "elsewhere", self.ud / "456")
|
||||
self.assertIsNotNone(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export(self.ud, "456/video/bg_1_20260101_000000", home=self.home)
|
||||
|
||||
def test_no_space_on_the_frame(self):
|
||||
full = mock.Mock(free=10)
|
||||
with mock.patch.object(clips.shutil, "disk_usage", return_value=full):
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not enough free space on the Frame"):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
|
||||
def test_old_exports_swept(self):
|
||||
work = self.home / clips.WORK
|
||||
old, new = work / "export-aaaaaaaaaaaaaaaa", work / "export-bbbbbbbbbbbbbbbb"
|
||||
write(old / "x.mp4"), write(new / "y.mp4")
|
||||
os.utime(old, (time.time() - 7 * 3600,) * 2)
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([])):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(new.exists())
|
||||
|
||||
def test_main_prints_json(self):
|
||||
with mock.patch.object(clips.Path, "home", return_value=self.home), \
|
||||
mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
|
||||
self.assertEqual(clips.main(["list"]), 0)
|
||||
self.assertEqual(json.loads(out.getvalue())["clips"][0]["id"], self.cid)
|
||||
with mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
|
||||
self.assertEqual(clips.main(["export", "../../x"]), 1)
|
||||
self.assertEqual(json.loads(out.getvalue()), {"error": "bad clip id"})
|
||||
|
||||
|
||||
ClipErrorOrOSError = (clips.ClipError, OSError)
|
||||
|
||||
|
||||
class ComputerSide(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
def test_export_copies_then_deletes_on_the_frame(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
calls = []
|
||||
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
|
||||
run = lambda cmd, timeout: calls.append(("run", cmd))
|
||||
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
|
||||
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
|
||||
self.assertEqual(calls[2][1], clips.remove_command(path))
|
||||
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
ran = []
|
||||
|
||||
def fetch(remote, local):
|
||||
Path(local).write_bytes(b"half")
|
||||
raise RuntimeError("connection lost")
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
|
||||
self.assertEqual(list(self.dir.iterdir()), [])
|
||||
self.assertEqual(ran, [clips.remove_command(path)])
|
||||
|
||||
def test_bad_id_never_reaches_the_frame(self):
|
||||
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
|
||||
|
||||
def test_unexpected_path_from_the_frame_refused(self):
|
||||
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
|
||||
folder=self.dir)
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
def test_record_command_with_and_without_sound(self):
|
||||
with_sound = shlex.split(clips.record_command("rec-20261020-201950", True))
|
||||
self.assertEqual(with_sound[:2], ["bash", "-c"])
|
||||
script = with_sound[2]
|
||||
self.assertIn(f"-f v4l2 -video_size 1920x1080 -i {clips.STREAM_DEVICE}", script)
|
||||
self.assertIn("-c:v libx264", script)
|
||||
self.assertNotIn("v4l2m2m", script) # the hardware encoder crashes ffmpeg
|
||||
self.assertIn(f"-f pulse -i {clips.AUDIO_SOURCE}", script)
|
||||
self.assertIn("-map 1:a:0", script)
|
||||
self.assertIn("-c:a aac", script)
|
||||
self.assertIn(f"-t {clips.MAX_SECONDS} ", script)
|
||||
self.assertIn('"$d/rec-20261020-201950.mkv"', script)
|
||||
self.assertEqual(script.count("-use_wallclock_as_timestamps 1"), 2)
|
||||
quiet = shlex.split(clips.record_command("rec-20261020-201950", False))[2]
|
||||
self.assertNotIn("pulse", quiet)
|
||||
self.assertNotIn("-c:a", quiet)
|
||||
self.assertEqual(quiet.count("-use_wallclock_as_timestamps 1"), 1)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.record_command("rec-20261020-201950", True, max_seconds=clips.MAX_SECONDS + 1)
|
||||
|
||||
def test_probe_checks_device_space_and_sound(self):
|
||||
probe = clips.probe_command()
|
||||
self.assertIn(f"[ -e {clips.STREAM_DEVICE} ]", probe)
|
||||
self.assertIn(f"-ge {clips.NEED_KB} ]", probe)
|
||||
self.assertIn(f"timeout 5 ffmpeg -hide_banner -loglevel error -nostdin -f pulse -i {clips.AUDIO_SOURCE}", probe)
|
||||
self.assertIn("echo audio=1; else echo audio=0", probe)
|
||||
self.assertIn(f'd="$HOME/{clips.WORK}"', probe)
|
||||
|
||||
def test_finish_remuxes_without_reencoding(self):
|
||||
cmd = clips.finish_command("rec-20261020-201950")
|
||||
self.assertIn("-i rec-20261020-201950.mkv -map 0 -c copy -movflags +faststart rec-20261020-201950.mp4", cmd)
|
||||
self.assertNotIn("libx264", cmd)
|
||||
self.assertEqual(clips.cleanup_command("rec-20261020-201950"), f'rm -f "$HOME/{clips.WORK}/rec-20261020-201950".*')
|
||||
|
||||
def test_stitch_command(self):
|
||||
self.assertEqual(clips.stitch_command(["a.mp4", "b.mp4"], "out.mp4"),
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", "a.mp4", "-i", "b.mp4",
|
||||
"-map", "0", "-map", "1", "-c", "copy", "-movflags", "+faststart", "out.mp4"])
|
||||
|
||||
|
||||
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
|
||||
# and finishes the file when q arrives on stdin. Like SteamOS's ffmpeg n7.0 (seen on a
|
||||
# Frame), it catches SIGTERM and SIGINT and carries on, exiting only after more than
|
||||
# three. FAKE_FFMPEG_RUN makes the recording end by itself after that many seconds (the
|
||||
# time limit); FAKE_FFMPEG_DEAF makes it ignore q too, so only SIGKILL ends it.
|
||||
FAKE_FFMPEG = r'''#!/usr/bin/env python3
|
||||
import json, os, signal, sys, time
|
||||
home = os.environ["HOME"]
|
||||
with open(os.path.join(home, "ffmpeg-calls.jsonl"), "a") as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + "\n")
|
||||
out = sys.argv[-1]
|
||||
if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the remux after a recording
|
||||
src = sys.argv[sys.argv.index("-i") + 1]
|
||||
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
|
||||
sys.exit(0)
|
||||
import threading
|
||||
stop, signals = [], []
|
||||
def caught(*a):
|
||||
signals.append(1)
|
||||
if len(signals) > 3:
|
||||
os._exit(255) # "Received > 3 system signals, hard exiting"
|
||||
signal.signal(signal.SIGTERM, caught)
|
||||
signal.signal(signal.SIGINT, caught)
|
||||
def keys():
|
||||
while True:
|
||||
ch = os.read(0, 1)
|
||||
if not ch:
|
||||
return
|
||||
if ch == b"q" and not os.environ.get("FAKE_FFMPEG_DEAF"):
|
||||
stop.append("q")
|
||||
threading.Thread(target=keys, daemon=True).start()
|
||||
f = open(out, "wb")
|
||||
f.write(b"v"); f.flush()
|
||||
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
|
||||
p.write(str(os.getpid()))
|
||||
os.replace(os.path.join(home, "ffmpeg.pid.tmp"), os.path.join(home, "ffmpeg.pid"))
|
||||
end = time.time() + float(os.environ.get("FAKE_FFMPEG_RUN", "60"))
|
||||
while not stop and time.time() < end:
|
||||
f.write(b"v"); f.flush(); time.sleep(0.05)
|
||||
f.close()
|
||||
open(os.path.join(home, "ffmpeg-ended"), "w").write(stop[0] if stop else "limit")
|
||||
sys.exit(255 if stop else 0)
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(POSIX, "the recording's shell is bash on the Frame")
|
||||
class Recording(unittest.TestCase):
|
||||
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.frame = self.dir / "frame" # the Frame's home
|
||||
(self.frame / clips.WORK).mkdir(parents=True)
|
||||
bin_dir = self.dir / "bin"
|
||||
write(bin_dir / "ffmpeg", FAKE_FFMPEG.encode()).chmod(0o755)
|
||||
self.env = {**os.environ, "HOME": str(self.frame), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
|
||||
self.here = self.dir / "Videos"
|
||||
self.channels, self.released, self.ran = [], [], []
|
||||
self.audio = "audio=1"
|
||||
|
||||
def open_channel(self, command, stderr):
|
||||
proc = subprocess.Popen(shlex.split(command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
|
||||
env=self.env)
|
||||
self.channels.append(proc)
|
||||
self.addCleanup(lambda: proc.poll() is None and proc.kill())
|
||||
return proc
|
||||
|
||||
def run_cmd(self, command, timeout):
|
||||
self.ran.append(command)
|
||||
if command == clips.probe_command():
|
||||
return self.audio + "\n" # needs /dev/video99, so answered here
|
||||
r = subprocess.run(["bash", "-c", command], capture_output=True, text=True, env=self.env, timeout=timeout)
|
||||
if r.returncode:
|
||||
raise RuntimeError(r.stderr.strip() or f"exit {r.returncode}")
|
||||
return r.stdout
|
||||
|
||||
def fetch(self, remote, local):
|
||||
shutil.copyfile(self.frame / remote, local)
|
||||
|
||||
def recorder(self):
|
||||
rec = clips.Recorder(self.open_channel, self.run_cmd, self.fetch,
|
||||
lambda fn: threading.Thread(target=fn, daemon=True).start(),
|
||||
release=self.released.append, folder=self.here)
|
||||
start = rec.start
|
||||
|
||||
def started():
|
||||
# "started" means ffmpeg was launched; wait until it's running, as it would be by a person's Stop.
|
||||
pid = self.frame / "ffmpeg.pid"
|
||||
for f in (pid, self.frame / "ffmpeg-ended"):
|
||||
f.unlink(missing_ok=True)
|
||||
got = start()
|
||||
for _ in range(200):
|
||||
if pid.exists() or (self.frame / "ffmpeg-ended").exists():
|
||||
break
|
||||
time.sleep(0.02)
|
||||
return got
|
||||
rec.start = started
|
||||
return rec
|
||||
|
||||
def wait_for(self, rec, states, timeout=20):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
s = rec.status()
|
||||
if s["state"] in states:
|
||||
return s
|
||||
time.sleep(0.05)
|
||||
self.fail(f"still {rec.status()}")
|
||||
|
||||
def ffmpeg_calls(self):
|
||||
return [json.loads(line) for line in (self.frame / "ffmpeg-calls.jsonl").read_text().splitlines()]
|
||||
|
||||
def test_stop_finishes_the_file_and_copies_it_here(self):
|
||||
rec = self.recorder()
|
||||
started = rec.start()
|
||||
self.assertEqual((started["state"], started["audio"], started["message"]), ("recording", True, "Recording"))
|
||||
self.assertIn("pulse", self.ffmpeg_calls()[0])
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(rec.stop()["state"], "stopping")
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q") # asked to finish, not killed
|
||||
saved = Path(done["file"])
|
||||
self.assertEqual(saved.parent, self.here)
|
||||
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
|
||||
self.assertTrue(saved.read_bytes().endswith(b"+mp4"))
|
||||
self.assertEqual([p.name for p in self.here.iterdir()], [saved.name]) # no .incoming- left
|
||||
self.assertEqual(list((self.frame / clips.WORK).iterdir()), []) # cleaned on the Frame
|
||||
self.assertIsNotNone(self.channels[0].poll()) # the channel has gone
|
||||
self.assertEqual(self.released, self.channels)
|
||||
self.assertEqual(self.ran[-1], clips.cleanup_command(saved.stem))
|
||||
|
||||
def test_channel_closing_stops_ffmpeg_on_the_frame(self):
|
||||
# The computer's ssh going away closes the channel just as Stop does.
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
self.channels[0].stdin.close()
|
||||
self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q")
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.05)
|
||||
|
||||
def test_an_ffmpeg_that_ignores_q_is_killed_not_left_recording(self):
|
||||
self.env["FAKE_FFMPEG_DEAF"] = "1"
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved", "failed"), timeout=30)
|
||||
self.assertFalse((self.frame / "ffmpeg-ended").exists()) # it never finished by itself
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.05)
|
||||
|
||||
def test_time_limit_saves_on_its_own(self):
|
||||
self.env["FAKE_FFMPEG_RUN"] = "0.3"
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "limit")
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
|
||||
rec.stop()
|
||||
|
||||
def test_no_sound_records_video_and_says_so(self):
|
||||
self.audio = "audio=0"
|
||||
rec = self.recorder()
|
||||
started = rec.start()
|
||||
self.assertFalse(started["audio"])
|
||||
self.assertIn("without sound", started["message"])
|
||||
self.assertNotIn("pulse", self.ffmpeg_calls()[0])
|
||||
rec.stop()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertIn("(no sound)", done["message"])
|
||||
|
||||
def test_one_at_a_time(self):
|
||||
rec = self.recorder()
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
|
||||
rec.stop()
|
||||
rec.start()
|
||||
with self.assertRaises(clips.ClipError) as e:
|
||||
rec.start()
|
||||
self.assertEqual(e.exception.status, 409)
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved",))
|
||||
rec.start() # a new one once the last is saved
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved",))
|
||||
|
||||
def test_failed_start_reports_why(self):
|
||||
rec = clips.Recorder(lambda cmd, err: subprocess.Popen(["bash", "-c", "echo 'no v4l2 here' >&2; exit 1"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err),
|
||||
lambda cmd, t: "audio=1", self.fetch, lambda fn: fn(), release=self.released.append,
|
||||
folder=self.here)
|
||||
with self.assertRaisesRegex(clips.ClipError, "no v4l2 here"):
|
||||
rec.start()
|
||||
self.assertEqual(rec.status()["state"], "failed")
|
||||
self.assertEqual(len(self.released), 1)
|
||||
|
||||
def test_failed_probe_reports_why(self):
|
||||
def run(cmd, timeout):
|
||||
raise clips.ClipError("No headset view device (/dev/video99). Is SteamVR running?")
|
||||
rec = clips.Recorder(mock.Mock(side_effect=AssertionError("recorded anyway")), run, self.fetch,
|
||||
lambda fn: fn(), folder=self.here)
|
||||
with self.assertRaisesRegex(clips.ClipError, "Is SteamVR running"):
|
||||
rec.start()
|
||||
self.assertEqual(rec.status()["state"], "failed")
|
||||
|
||||
def test_empty_recording_is_an_error_not_a_file(self):
|
||||
self.env["FAKE_FFMPEG_RUN"] = "0"
|
||||
rec = self.recorder()
|
||||
with mock.patch.object(clips, "finish_command", lambda name: f"echo 'The recording is empty' >&2; exit 1"):
|
||||
rec.start()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "failed")
|
||||
self.assertIn("The recording is empty", done["error"])
|
||||
self.assertFalse(self.here.exists() and any(self.here.iterdir()))
|
||||
self.assertEqual(list((self.frame / clips.WORK).iterdir()), [])
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "needs ffmpeg and ffprobe")
|
||||
class RealFfmpeg(unittest.TestCase):
|
||||
"""A 65 s recording in Steam's DASH naming (ffmpeg's own dash muxer names files the same
|
||||
way), stitched by the real ffmpeg: longer than the 60 s Steam's own export manages."""
|
||||
|
||||
def test_export_of_a_long_recording(self):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
|
||||
ud = d / "home" / clips.STEAM_USERDATA
|
||||
folder = ud / "123" / "gamerecordings" / "video" / "bg_620_20261020_201950"
|
||||
folder.mkdir(parents=True)
|
||||
r = subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=8000", "-t", "65",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-g", "20", "-c:a", "aac",
|
||||
"-seg_duration", "2", "-use_template", "1", "-use_timeline", "0",
|
||||
"-f", "dash", str(folder / "session.mpd")], capture_output=True, text=True)
|
||||
if r.returncode != 0 or not (folder / "chunk-stream0-00001.m4s").exists():
|
||||
self.skipTest("this ffmpeg can't write DASH with libx264: " + r.stderr[-300:])
|
||||
listed = clips.list_recordings(ud)
|
||||
self.assertEqual(len(listed), 1)
|
||||
self.assertGreater(listed[0]["chunks"], 30)
|
||||
got = clips.export(ud, listed[0]["id"], home=d / "home")
|
||||
out = d / "home" / got["path"]
|
||||
probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(out)],
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
self.assertEqual(sorted(s["codec_type"] for s in probe["streams"]), ["audio", "video"])
|
||||
self.assertGreater(float(probe["format"]["duration"]), 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Force quit (ui/frame_running.py and /api/running) without a headset: running-game
|
||||
detection, which processes may be signalled, SIGTERM-then-SIGKILL, and the Android path.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_running # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
ME, OTHER = 1000, 0
|
||||
HOME = frame_running.HOME
|
||||
REAPER = f"{HOME}/.local/share/Steam/ubuntu12_32/reaper"
|
||||
|
||||
|
||||
def proc(ppid, comm, argv=None, uid=ME, start=None, state="S"):
|
||||
return {"ppid": ppid, "comm": comm, "argv": argv if argv is not None else [comm], "uid": uid,
|
||||
"start": start or 1000 + ppid, "state": state}
|
||||
|
||||
|
||||
def frame():
|
||||
"""A Frame's processes, as Steam on Linux launches games: Steam's reaper with
|
||||
AppId= is the parent of the game (Portal 2 here, appid 620)."""
|
||||
return {
|
||||
1: proc(0, "systemd", uid=OTHER),
|
||||
200: proc(1, "sshd", uid=OTHER),
|
||||
201: proc(200, "sshd-session"),
|
||||
202: proc(201, "bash"),
|
||||
203: proc(202, "python3", ["python3", "-", "quit", "620"]), # this helper
|
||||
300: proc(1, "systemd", ["/usr/lib/systemd/systemd", "--user"]),
|
||||
301: proc(300, "gamescope-session"),
|
||||
302: proc(301, "gamescope"),
|
||||
303: proc(302, "steam", [f"{HOME}/.local/share/Steam/ubuntu12_32/steam", "-cef-enable-debugging"]),
|
||||
304: proc(303, "steamwebhelper"),
|
||||
305: proc(300, "vrserver"),
|
||||
306: proc(300, "plasmashell"),
|
||||
307: proc(300, "pipewire"),
|
||||
400: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "steam-launch-wrapper", "--", "portal2.sh"]),
|
||||
401: proc(400, "steam-launch-wr", ["steam-launch-wrapper", "--", "portal2.sh"]),
|
||||
402: proc(401, "portal2.sh", ["/bin/sh", "portal2.sh"]),
|
||||
403: proc(402, "portal2_linux", ["./portal2_linux", "-game", "portal2"]),
|
||||
404: proc(403, "steam", ["steam"]), # a child named like Steam: left alone
|
||||
405: proc(404, "crashhandler"), # and so is anything under it
|
||||
# Another game, and processes that only look like launches.
|
||||
500: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=999", "--", "other"]),
|
||||
501: proc(500, "other"),
|
||||
600: proc(1, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "x"], uid=OTHER),
|
||||
601: proc(300, "bash", ["bash", "-c", "echo reaper SteamLaunch AppId=620"]),
|
||||
602: proc(300, "fakereaper", ["/tmp/notreaper", "SteamLaunch", "AppId=620"]),
|
||||
}
|
||||
|
||||
|
||||
class Parsing(unittest.TestCase):
|
||||
def test_launch_appid(self):
|
||||
cases = [
|
||||
([REAPER, "SteamLaunch", "AppId=620", "--", "game"], 620),
|
||||
(["reaper", "SteamLaunch", "AppId=3346865537", "--", "lepton-app.sh"], 3346865537),
|
||||
([REAPER, "AppId=620", "--", "game"], None), # no SteamLaunch
|
||||
([REAPER, "SteamLaunch", "--", "game", "AppId=620"], None), # the game's own arguments
|
||||
(["/usr/bin/steam", "SteamLaunch", "AppId=620"], None), # not the reaper
|
||||
(["/tmp/reaper-ish", "SteamLaunch", "AppId=620"], None),
|
||||
([REAPER, "SteamLaunch", "AppId=62x0"], None),
|
||||
([REAPER, "SteamLaunch", "AppId=12345678901"], None), # more than 10 digits
|
||||
([], None),
|
||||
]
|
||||
for argv, want in cases:
|
||||
with self.subTest(argv=argv):
|
||||
self.assertEqual(frame_running.launch_appid(argv), want)
|
||||
|
||||
def test_reapers_are_this_users_live_launches(self):
|
||||
procs = frame()
|
||||
procs[700] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=777"], state="Z")
|
||||
self.assertEqual(frame_running.reapers(procs, ME), {620: [400], 999: [500]})
|
||||
|
||||
def test_steamvr_is_never_a_game(self):
|
||||
procs = frame()
|
||||
procs[710] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=250820", "--", "vrstartup"])
|
||||
procs[711] = proc(710, "vrcompositor-launcher")
|
||||
self.assertNotIn(250820, frame_running.reapers(procs, ME))
|
||||
found = frame_running.running(procs, ME, steam=lambda ids: ([250820], {}),
|
||||
android=dict, containers=set)
|
||||
self.assertNotIn(250820, [a["appid"] for a in found])
|
||||
|
||||
def test_gameid(self):
|
||||
self.assertEqual(frame_running.gameid(620), "620")
|
||||
# A non-Steam shortcut: (appid << 32) | 0x02000000, as steam_shortcuts.py stop sends.
|
||||
self.assertEqual(frame_running.gameid(3346865537), str((3346865537 << 32) | 0x02000000))
|
||||
|
||||
def test_snapshot_reads_proc(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/42")
|
||||
os.makedirs(f"{tmp}/self")
|
||||
fields = " ".join(["S", "7"] + ["0"] * 17 + ["123456"])
|
||||
Path(f"{tmp}/42/stat").write_text(f"42 (my (game) x) {fields} 0 0\n")
|
||||
Path(f"{tmp}/42/cmdline").write_bytes(b"reaper\0SteamLaunch\0AppId=5\0--\0g\0")
|
||||
os.makedirs(f"{tmp}/43") # vanished between listdir and read
|
||||
got = frame_running.snapshot(tmp)
|
||||
self.assertEqual(list(got), [42])
|
||||
self.assertEqual(got[42]["comm"], "my (game) x")
|
||||
self.assertEqual((got[42]["ppid"], got[42]["start"], got[42]["state"]), (7, 123456, "S"))
|
||||
self.assertEqual(got[42]["argv"], ["reaper", "SteamLaunch", "AppId=5", "--", "g"])
|
||||
|
||||
|
||||
class Detection(unittest.TestCase):
|
||||
def run_list(self, procs, steam=(None, {}), android=None, containers=()):
|
||||
return frame_running.running(procs, ME, steam=lambda ids: steam, android=lambda: android or {},
|
||||
containers=lambda: set(containers))
|
||||
|
||||
def test_steam_game_named_by_steam(self):
|
||||
got = self.run_list(frame(), steam=([620], {620: "Portal 2", 999: "Other"}))
|
||||
self.assertEqual([(a["appid"], a["kind"], a["name"]) for a in got],
|
||||
[(620, "steam", "Portal 2"), (999, "steam", "Other")])
|
||||
self.assertEqual(got[0]["gameid"], "620")
|
||||
self.assertEqual(got[0]["processes"], 6)
|
||||
|
||||
def test_without_steam_the_manifest_names_it(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/steamapps")
|
||||
Path(f"{tmp}/steamapps/appmanifest_620.acf").write_text('"AppState"\n{\n\t"appid"\t"620"\n\t"name"\t"Portal 2"\n}')
|
||||
with mock.patch.object(frame_running, "STEAM", tmp):
|
||||
got = self.run_list(frame())
|
||||
self.assertEqual([(a["appid"], a["name"]) for a in got], [(620, "Portal 2"), (999, "App 999")])
|
||||
|
||||
def test_nothing_running(self):
|
||||
procs = {pid: p for pid, p in frame().items() if pid < 400}
|
||||
self.assertEqual(self.run_list(procs), [])
|
||||
|
||||
def test_steam_only_listing_counts(self):
|
||||
# Steam says it's running but no reaper was found: listed, with no processes.
|
||||
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, steam=([620], {620: "Portal 2"}))
|
||||
self.assertEqual(got, [{"appid": 620, "gameid": "620", "processes": 0, "kind": "steam", "name": "Portal 2"}])
|
||||
|
||||
def test_devkit_title_and_shortcut(self):
|
||||
sid, other = 0x80001234, 0x80005678
|
||||
procs = {1: proc(0, "systemd", uid=OTHER),
|
||||
10: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={sid}", "--", f'"{HOME}/devkit-game/Cool_Game/run.sh"']),
|
||||
11: proc(10, "run.sh", ["/bin/sh", f"{HOME}/devkit-game/Cool_Game/run.sh"]),
|
||||
20: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={other}", "--", "/usr/bin/flatpak", "run", "x"])}
|
||||
got = self.run_list(procs)
|
||||
self.assertEqual([(a["kind"], a.get("title"), a["name"]) for a in got],
|
||||
[("devkit", "Cool_Game", "Cool_Game"), ("shortcut", None, f"Shortcut {other}")])
|
||||
self.assertEqual(got[0]["gameid"], str((sid << 32) | 0x02000000))
|
||||
|
||||
def test_android_app_from_its_container(self):
|
||||
meta = {"package": "org.example.game", "label": "Example", "shortcut": 3346865537, "instance": 2812345678}
|
||||
apps = {3346865537: meta, 2812345678: meta}
|
||||
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps,
|
||||
containers={"lepton-steamlaunch-2812345678"})
|
||||
self.assertEqual(got, [{"appid": 3346865537, "gameid": str((3346865537 << 32) | 0x02000000), "processes": 0,
|
||||
"kind": "android", "package": "org.example.game", "name": "Example"}])
|
||||
# Its container stopped: not running.
|
||||
self.assertEqual(self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps, containers=()), [])
|
||||
|
||||
def test_android_meta_reads_shortcut_and_instance(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(f"{tmp}/org.example.game")
|
||||
Path(f"{tmp}/org.example.game/meta.json").write_text(json.dumps(
|
||||
{"package": "org.example.game", "shortcut": 3346865537, "instance": 2812345678}))
|
||||
os.makedirs(f"{tmp}/broken")
|
||||
Path(f"{tmp}/broken/meta.json").write_text("{")
|
||||
with mock.patch.object(frame_running, "ANDROID", tmp):
|
||||
self.assertEqual(sorted(frame_running.android_apps()), [2812345678, 3346865537])
|
||||
|
||||
|
||||
class World:
|
||||
"""Processes that react to signals: kill() removes them, unless they ignore it."""
|
||||
|
||||
def __init__(self, procs, ignore=(), unkillable=()):
|
||||
self.procs, self.ignore, self.unkillable = dict(procs), set(ignore), set(unkillable)
|
||||
self.sent, self.now = [], 0.0
|
||||
|
||||
def scan(self):
|
||||
return dict(self.procs)
|
||||
|
||||
def kill(self, pid, sig):
|
||||
if pid not in self.procs:
|
||||
raise ProcessLookupError
|
||||
self.sent.append((pid, sig))
|
||||
if pid in self.unkillable or (sig == frame_running.SIGTERM and pid in self.ignore):
|
||||
return
|
||||
self.procs.pop(pid)
|
||||
# A reaper exits once its last child has.
|
||||
for rid, p in list(self.procs.items()):
|
||||
if p["comm"] == "reaper" and not any(c["ppid"] == rid for c in self.procs.values()):
|
||||
self.procs.pop(rid)
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
def escalate(self, appid=620):
|
||||
return frame_running.escalate(appid, me=203, uid=ME, scan=self.scan, kill=self.kill,
|
||||
sleep=self.sleep, clock=lambda: self.now)
|
||||
|
||||
|
||||
class Escalation(unittest.TestCase):
|
||||
GAME = {401, 402, 403}
|
||||
|
||||
def assert_only_game(self, world, allowed=GAME | {400}):
|
||||
signalled = {pid for pid, _ in world.sent}
|
||||
self.assertTrue(signalled, "nothing was signalled")
|
||||
self.assertLessEqual(signalled, allowed)
|
||||
protected = frame_running.protected(frame(), 203, ME)
|
||||
for pid in (1, 200, 201, 202, 203, 300, 301, 302, 303, 304, 305, 306, 307, 404, 405, 500, 501, 600, 601, 602):
|
||||
self.assertNotIn(pid, signalled)
|
||||
self.assertTrue({1, 201, 202, 203, 302, 303, 304, 305, 306, 307, 404, 600}.issubset(protected))
|
||||
|
||||
def test_sigterm_ends_it(self):
|
||||
world = World(frame())
|
||||
self.assertEqual(world.escalate(), "term")
|
||||
self.assert_only_game(world)
|
||||
self.assertEqual({pid for pid, sig in world.sent}, self.GAME)
|
||||
self.assertTrue(all(sig == frame_running.SIGTERM for _, sig in world.sent))
|
||||
# Deepest first, and the reaper isn't signalled while its children are alive.
|
||||
self.assertEqual([pid for pid, _ in world.sent], [403, 402, 401])
|
||||
self.assertNotIn(400, world.procs)
|
||||
self.assertIn(404, world.procs) # the "steam" child under the game is never touched
|
||||
|
||||
def test_sigkill_after_a_grace_period(self):
|
||||
world = World(frame(), ignore={403})
|
||||
self.assertEqual(world.escalate(), "kill")
|
||||
self.assert_only_game(world)
|
||||
self.assertIn((403, frame_running.SIGTERM), world.sent)
|
||||
self.assertIn((403, frame_running.SIGKILL), world.sent)
|
||||
self.assertGreaterEqual(world.now, frame_running.TERM_WAIT)
|
||||
kills = [pid for pid, sig in world.sent if sig == frame_running.SIGKILL]
|
||||
self.assertEqual(kills[0], 403)
|
||||
|
||||
def test_survivor_is_an_error(self):
|
||||
world = World(frame(), unkillable={403})
|
||||
with self.assertRaisesRegex(frame_running.Fail, "still running after SIGKILL"):
|
||||
world.escalate()
|
||||
self.assert_only_game(world)
|
||||
|
||||
def test_reused_pid_is_not_signalled(self):
|
||||
world = World(frame(), ignore={402, 403})
|
||||
real_scan = world.scan
|
||||
|
||||
def scan():
|
||||
procs = real_scan()
|
||||
if world.sent and 402 in procs: # 402 exits and its pid goes to an unrelated program
|
||||
procs[402] = proc(300, "editor", start=99999)
|
||||
world.procs[402] = procs[402]
|
||||
return procs
|
||||
world.scan = scan
|
||||
world.escalate()
|
||||
self.assertNotIn((402, frame_running.SIGKILL), world.sent)
|
||||
|
||||
def test_nothing_left(self):
|
||||
world = World({pid: p for pid, p in frame().items() if pid < 400})
|
||||
self.assertEqual(world.escalate(), "gone")
|
||||
self.assertEqual(world.sent, [])
|
||||
|
||||
def test_lingering_reaper_alone(self):
|
||||
procs = {pid: p for pid, p in frame().items() if pid not in self.GAME and pid not in (404, 405)}
|
||||
world = World(procs)
|
||||
self.assertEqual(world.escalate(), "term")
|
||||
self.assertEqual(world.sent, [(400, frame_running.SIGTERM)])
|
||||
|
||||
def test_protected_never_signalled_even_as_the_root(self):
|
||||
# A reaper whose only children are protected: nothing to signal but the reaper itself.
|
||||
procs = frame()
|
||||
procs[402]["comm"] = "gamescope"
|
||||
world = World(procs)
|
||||
world.escalate()
|
||||
self.assertNotIn(402, {pid for pid, _ in world.sent})
|
||||
self.assertNotIn(403, {pid for pid, _ in world.sent})
|
||||
|
||||
|
||||
class Quit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.page = mock.MagicMock()
|
||||
patches = [mock.patch.object(frame_running, "Page", return_value=self.page),
|
||||
mock.patch.object(frame_running, "running", return_value=[
|
||||
{"appid": 620, "gameid": "620", "name": "Portal 2", "kind": "steam", "processes": 4}]),
|
||||
mock.patch.object(frame_running.os, "getuid", return_value=ME, create=True)] # none on Windows
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_steam_terminate_first(self):
|
||||
scans = iter([frame(), {}])
|
||||
self.page.eval.return_value = {"running": [], "names": {}}
|
||||
with mock.patch.object(frame_running, "snapshot", side_effect=lambda: next(scans)), \
|
||||
mock.patch.object(frame_running, "escalate") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=lambda s: None)
|
||||
self.assertEqual(out["method"], "steam")
|
||||
self.page.eval.assert_any_call('SteamClient.Apps.TerminateApp("620", false)')
|
||||
escalate.assert_not_called()
|
||||
|
||||
def test_signals_when_steam_leaves_it_running(self):
|
||||
now = [0.0]
|
||||
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
|
||||
mock.patch.object(frame_running, "escalate", return_value="kill") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=lambda s: now.__setitem__(0, now[0] + s), clock=lambda: now[0])
|
||||
escalate.assert_called_once_with(620)
|
||||
self.assertGreaterEqual(now[0], frame_running.STEAM_WAIT)
|
||||
self.assertEqual(out["message"], "Force quit Portal 2 (it had to be killed)")
|
||||
|
||||
def test_without_steam_goes_straight_to_signals(self):
|
||||
frame_running.Page.side_effect = frame_running.Fail("no SharedJSContext page")
|
||||
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
|
||||
mock.patch.object(frame_running, "escalate", return_value="term") as escalate:
|
||||
out = frame_running.quit_game(620, sleep=self.fail)
|
||||
escalate.assert_called_once_with(620)
|
||||
self.assertEqual(out["method"], "term")
|
||||
|
||||
def test_not_running(self):
|
||||
frame_running.running.return_value = []
|
||||
with mock.patch.object(frame_running, "escalate") as escalate:
|
||||
self.assertEqual(frame_running.quit_game(620)["method"], "gone")
|
||||
escalate.assert_not_called()
|
||||
self.page.eval.assert_not_called()
|
||||
|
||||
|
||||
class Script(unittest.TestCase):
|
||||
def test_server_sends_it_with_frame_steam_inside(self):
|
||||
import server
|
||||
with mock.patch.object(server, "ssh", return_value='{"running": []}') as ssh:
|
||||
self.assertEqual(server.running_games(), {"running": [], "message": "Nothing is running on the Frame"})
|
||||
source = ssh.call_args.kwargs["stdin"]
|
||||
self.assertEqual(ssh.call_args.args[0], "python3 - list")
|
||||
# The same script runs as `python3 -`: frame_steam resolves, and bad usage is one JSON error.
|
||||
r = subprocess.run([sys.executable, "-I", "-", "bogus"], input=source, capture_output=True, text=True,
|
||||
cwd=tempfile.gettempdir(), timeout=30)
|
||||
self.assertEqual(r.returncode, 1, r.stderr)
|
||||
self.assertEqual(json.loads(r.stdout), {"error": "usage: list | quit APPID"})
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import server
|
||||
self.server = server
|
||||
|
||||
def quit(self, listed, quit_result=None, stop_error=None):
|
||||
calls = []
|
||||
|
||||
def frame(*args, timeout=40):
|
||||
calls.append(args)
|
||||
return {"running": listed} if args == ("list",) else quit_result
|
||||
with mock.patch.object(self.server, "running_frame", side_effect=frame), \
|
||||
mock.patch.object(self.server, "ensure_master"), \
|
||||
mock.patch.object(self.server.frame_android, "stop", side_effect=stop_error) as stop:
|
||||
out = self.server.force_quit({"action": "quit", "appid": "3346865537"})
|
||||
return out, calls, stop
|
||||
|
||||
def test_android_app_stops_its_container_first(self):
|
||||
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
|
||||
out, calls, stop = self.quit([app], {"name": "Example", "method": "gone", "message": "That game isn't running"})
|
||||
stop.assert_called_once_with("org.example.game")
|
||||
self.assertEqual(calls, [("list",), ("quit", "3346865537")])
|
||||
self.assertEqual(out["message"], "Stopped Example")
|
||||
|
||||
def test_android_stop_failure_falls_back_to_processes(self):
|
||||
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
|
||||
err = self.server.frame_android.FrameError("podman failed")
|
||||
out, calls, _ = self.quit([app], {"name": "Example", "method": "term", "message": "Force quit Example"}, err)
|
||||
self.assertEqual(calls[-1], ("quit", "3346865537"))
|
||||
self.assertEqual(out["message"], "Force quit Example")
|
||||
|
||||
def test_other_games_skip_the_android_path(self):
|
||||
app = {"appid": 3346865537, "kind": "devkit", "title": "Cool_Game", "name": "Cool_Game"}
|
||||
out, _, stop = self.quit([app], {"name": "Cool_Game", "method": "steam", "message": "Quit Cool_Game"})
|
||||
stop.assert_not_called()
|
||||
self.assertEqual(out["message"], "Quit Cool_Game")
|
||||
|
||||
def test_not_running_is_a_conflict(self):
|
||||
with self.assertRaises(self.server.Failure) as cm:
|
||||
self.quit([])
|
||||
self.assertEqual(cm.exception.status, 409)
|
||||
|
||||
|
||||
class Routes(test_server.ServerGuards):
|
||||
"""Validation runs before any SSH."""
|
||||
|
||||
def test_input_validation(self):
|
||||
for body in ({"action": "quit", "appid": "620; reboot"}, {"action": "quit", "appid": ""},
|
||||
{"action": "kill", "appid": "620"}, {"appid": "620"}, {"action": "quit", "appid": "12345678901"}):
|
||||
status, payload = self.post("/api/running", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_routes_need_custom_header(self):
|
||||
self.assertEqual(self.request("GET", "/api/running")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/running", {"action": "quit", "appid": "620"})[0], 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -1,862 +0,0 @@
|
||||
"""My itch.io purchases: the account key, owned games and their Android downloads.
|
||||
|
||||
Offline: a fake HTTPS layer under urllib's real opener (so redirects and header
|
||||
handling are the real ones) serves the recorded JSON in fixtures/itch_account.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import contextlib, email.message, http.client, io, ipaddress, json, os, socket, sys, tempfile, threading, time
|
||||
import unittest
|
||||
import urllib.error, urllib.parse, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, SourceLimited, _itch_account as account, _web, itch, search
|
||||
import server
|
||||
|
||||
FIX = Path(__file__).parent / 'fixtures' / 'itch_account'
|
||||
KEY = 'TESTKEYabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
OTHER = 'OTHERKEY0123456789abcdefghijklmnopqrstuv'
|
||||
|
||||
|
||||
def apk_bytes():
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, 'w') as z:
|
||||
z.writestr('AndroidManifest.xml', b'\x03\x00\x08\x00')
|
||||
z.writestr('classes.dex', b'dex\n035\x00')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class Reply(io.BytesIO):
|
||||
"""Just enough of http.client.HTTPResponse for urllib's handlers."""
|
||||
|
||||
def __init__(self, url, code=200, body=b'', headers=None):
|
||||
super().__init__(body if isinstance(body, bytes) else json.dumps(body).encode())
|
||||
self.url, self.code, self.status, self.msg, self.reason = url, code, code, 'fake', 'fake'
|
||||
self.headers = email.message.Message()
|
||||
for k, v in (headers or {}).items():
|
||||
self.headers[k] = v
|
||||
|
||||
def info(self):
|
||||
return self.headers
|
||||
|
||||
def geturl(self):
|
||||
return self.url
|
||||
|
||||
def getcode(self):
|
||||
return self.code
|
||||
|
||||
def getheader(self, name, default=None):
|
||||
return self.headers.get(name, default)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
"""_web._connect's http.client connection, answered from the same routes."""
|
||||
|
||||
def __init__(self, web, host):
|
||||
self.web, self.host, self.reply = web, host, None
|
||||
|
||||
def request(self, method, path, headers=None):
|
||||
req = urllib.request.Request('https://' + self.host + path, headers=headers or {})
|
||||
self.reply = self.web(None, req)
|
||||
|
||||
def getresponse(self):
|
||||
return self.reply
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# What the fake resolver answers: the storage host is public; the rest are traps.
|
||||
ADDRESSES = {'storage.itch-cdn.example': '151.101.1.1', 'other-cdn.example': '151.101.2.2',
|
||||
'localhost.': '127.0.0.1', 'frame.local': '192.168.1.20', '127.0.0x0.1': '127.0.0.1',
|
||||
'intranet.example': '10.0.0.5', 'mapped.example': '::ffff:127.0.0.1', 'cgnat.example': '100.64.0.1',
|
||||
'mixed.example': ('151.101.1.1', '10.0.0.5'), 'dual.example': ('2a04:4e42::81', '151.101.1.1'),
|
||||
'nat64.example': '64:ff9b:1::a00:1', 'sitelocal.example': 'fec0::1',
|
||||
'many.example': tuple('151.101.%d.1' % n for n in range(1, 9))}
|
||||
REAL_CONNECT = _web._connect
|
||||
|
||||
|
||||
class FakeItch:
|
||||
"""Routes https requests by host and path; records every request with its headers."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls, self.routes = [], {}
|
||||
|
||||
def route(self, host_path, reply):
|
||||
self.routes[host_path] = reply
|
||||
|
||||
def __call__(self, handler, req):
|
||||
url = urllib.parse.urlsplit(req.full_url)
|
||||
headers = {k.lower(): v for k, v in req.header_items()}
|
||||
self.calls.append((req.full_url, headers))
|
||||
reply = self.routes.get(url.hostname + url.path)
|
||||
if reply is None:
|
||||
return Reply(req.full_url, 404, {'errors': ['not found']})
|
||||
if callable(reply):
|
||||
reply = reply(req, urllib.parse.parse_qs(url.query))
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
if isinstance(reply, Reply):
|
||||
return reply
|
||||
return Reply(req.full_url, 200, reply)
|
||||
|
||||
def urls(self, host_path):
|
||||
return [u for u, _ in self.calls if urllib.parse.urlsplit(u).hostname + urllib.parse.urlsplit(u).path == host_path]
|
||||
|
||||
|
||||
def owned_pages(req, query):
|
||||
page = query['page'][0]
|
||||
return {'1': json.loads((FIX / 'owned-keys-1.json').read_text()),
|
||||
'2': json.loads((FIX / 'owned-keys-2.json').read_text())}.get(page, {'page': int(page), 'owned_keys': []})
|
||||
|
||||
|
||||
class ItchPurchases(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
root = Path(self.tmp.name)
|
||||
for target, value in [(account.frame_host, 'data_dir'), (search.frame_host, 'data_dir')]:
|
||||
p = patch.object(target, value, lambda *parts: root.joinpath('data', *parts))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
(root / 'cache').mkdir()
|
||||
for p in (patch.object(_web, 'cache', return_value=str(root / 'cache')),
|
||||
patch.object(_web.frame_host, 'cache_dir', lambda *parts: root.joinpath('cache', *parts))):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.web = FakeItch()
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player', 'id': 1}})
|
||||
self.web.route('api.itch.io/profile/owned-keys', owned_pages)
|
||||
self.web.route('api.itch.io/games/341190/uploads', json.loads((FIX / 'uploads.json').read_text()))
|
||||
p = patch('urllib.request.HTTPSHandler.https_open', autospec=True, side_effect=self.web)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = patch('urllib.request.HTTPHandler.http_open', side_effect=AssertionError('plain HTTP in test'))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
real_lookup = _web.socket.getaddrinfo
|
||||
|
||||
def lookup(host, port, *args, **kwargs):
|
||||
if host not in ADDRESSES:
|
||||
try:
|
||||
ipaddress.ip_address(host) # IP literals (and the in-process test server) need no DNS
|
||||
return real_lookup(host, port, *args, **kwargs)
|
||||
except ValueError:
|
||||
pass
|
||||
if host == 'localhost':
|
||||
return real_lookup(host, port, *args, **kwargs)
|
||||
raise _web.socket.gaierror('unknown test host ' + host)
|
||||
found = ADDRESSES[host]
|
||||
return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in ([found] if isinstance(found, str) else found)]
|
||||
self.connected, self.unreachable = [], set()
|
||||
|
||||
def connect(host, address, timeout, deadline):
|
||||
self.connected.append((host, address))
|
||||
if address[0] in self.unreachable:
|
||||
raise OSError('Network is unreachable')
|
||||
self.assertLessEqual(deadline - time.monotonic(), _web.CONNECT_BUDGET)
|
||||
return FakeConnection(self.web, host)
|
||||
for p in (patch.object(_web.socket, 'getaddrinfo', lookup), patch.object(_web, '_connect', connect)):
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
account._forget()
|
||||
self.addCleanup(account._forget)
|
||||
_web._limited.clear()
|
||||
self.addCleanup(_web._limited.clear)
|
||||
search._status.clear()
|
||||
|
||||
def owned_source(self):
|
||||
return next(s for s in itch.sources() if s['id'] == itch.OWNED)
|
||||
|
||||
def connect(self):
|
||||
self.assertTrue(account.connect(KEY)['itch']['connected'])
|
||||
|
||||
# -- the key ---------------------------------------------------------------
|
||||
|
||||
def test_connect_checks_the_key_then_saves_it_0600(self):
|
||||
result = account.connect(' ' + KEY + '\n')
|
||||
self.assertEqual(result['message'], 'Connected to itch.io as player')
|
||||
self.assertEqual(result['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertNotIn(KEY, json.dumps(result))
|
||||
url, headers = self.web.calls[0]
|
||||
self.assertEqual(url, 'https://api.itch.io/profile')
|
||||
self.assertEqual(headers['authorization'], 'Bearer ' + KEY)
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
|
||||
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json']) # no .tmp left
|
||||
self.assertNotIn(KEY, json.dumps(account.status()))
|
||||
|
||||
def test_reconnecting_rewrites_the_file_0600_even_if_it_was_wider(self):
|
||||
self.connect()
|
||||
os.chmod(account.path(), 0o644)
|
||||
account.connect(OTHER)
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], OTHER)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
|
||||
|
||||
def test_bad_or_revoked_key_stays_disconnected(self):
|
||||
for reply in (Reply('https://api.itch.io/profile', 401, {'errors': ['invalid key']}),
|
||||
Reply('https://api.itch.io/profile', 403, b'Forbidden'),
|
||||
{'errors': ['invalid key']}):
|
||||
self.web.route('api.itch.io/profile', reply)
|
||||
with self.assertRaisesRegex(account.BadKey, 'did not accept the API key') as caught:
|
||||
account.connect(KEY)
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertEqual(account.status(), {'connected': False, 'username': None})
|
||||
self.assertEqual([s['id'] for s in itch.sources()], ['itch', 'itch-quest'])
|
||||
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
|
||||
account.connect('short')
|
||||
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
|
||||
account.connect(KEY + '\r\nX-Injected: 1')
|
||||
self.assertEqual(len(self.web.calls), 3) # malformed keys never reach itch.io
|
||||
|
||||
def test_a_refused_request_with_a_working_key_keeps_the_key(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/games/341190/uploads', Reply('https://api.itch.io/games/341190/uploads', 403, b''))
|
||||
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
|
||||
itch.details(self.owned_source(), '341190')
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
|
||||
def test_a_key_revoked_later_is_removed_with_a_clear_message(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', Reply('https://api.itch.io/profile/owned-keys', 401, b''))
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
|
||||
itch.search(self.owned_source(), '')
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
|
||||
def test_disconnect_deletes_the_key_file(self):
|
||||
self.connect()
|
||||
self.assertIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
result = account.disconnect()
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertEqual(result['itch'], {'connected': False, 'username': None})
|
||||
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
|
||||
with self.assertRaisesRegex(SourceError, 'Connect your itch.io account first'):
|
||||
account.owned_keys()
|
||||
account.disconnect() # already gone: fine
|
||||
|
||||
# -- owned games -------------------------------------------------------------
|
||||
|
||||
def test_owned_keys_follow_pages_until_an_empty_one(self):
|
||||
self.connect()
|
||||
keys = account.owned_keys()
|
||||
self.assertEqual([k['id'] for k in keys], [9001, 9002, 8001, 8002])
|
||||
pages = self.web.urls('api.itch.io/profile/owned-keys')
|
||||
self.assertEqual([urllib.parse.parse_qs(urllib.parse.urlsplit(u).query) for u in pages],
|
||||
[{'page': [str(n)], 'per_page': ['500']} for n in (1, 2, 3)])
|
||||
self.assertTrue(all('Bearer ' + KEY == h['authorization'] for _, h in self.web.calls))
|
||||
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
|
||||
account.owned_keys() # cached in memory, not on disk
|
||||
self.assertEqual(len(self.web.urls('api.itch.io/profile/owned-keys')), 3)
|
||||
self.assertFalse(any(KEY in p.read_text(errors='ignore') or b'Orbit' in p.read_bytes()
|
||||
for p in Path(_web.cache()).iterdir()))
|
||||
|
||||
def test_page_cap_stops_a_server_that_never_ends(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', lambda req, q: {'owned_keys': [{'id': int(q['page'][0])}]})
|
||||
with patch.object(account, 'PAGES', 3):
|
||||
self.assertEqual(len(account.owned_keys()), 3)
|
||||
|
||||
def test_search_lists_owned_android_games_labelled(self):
|
||||
self.connect()
|
||||
entries = itch.search(self.owned_source(), '')
|
||||
self.assertEqual([(e['id'], e['name']) for e in entries], [('341190', 'Orbit Garden VR'), ('77', 'Free Quest Toy')])
|
||||
orbit, toy = entries
|
||||
self.assertEqual(orbit['download_key_id'], 9001) # the newest key for a game owned twice
|
||||
self.assertTrue(orbit['owned'])
|
||||
self.assertEqual(orbit['owned_label'], 'Owned on itch.io')
|
||||
self.assertFalse(orbit['free'])
|
||||
self.assertTrue(toy['free'])
|
||||
self.assertIsNone(toy['icon']) # not an itch.io image host
|
||||
self.assertEqual(orbit['page'], 'https://maker.itch.io/orbit-garden')
|
||||
self.assertEqual(itch.search(self.owned_source(), 'zero gravity')[0]['id'], '341190')
|
||||
# Through the store's search, owned (paid) games pass the free-only filter.
|
||||
result = search.search('orbit', source=itch.OWNED)
|
||||
self.assertEqual(result['sources'][0]['status'], 'ok')
|
||||
offer = result['apps'][0]['offers'][0]
|
||||
self.assertEqual((offer['source'], offer['source_name'], offer['owned_label']),
|
||||
(itch.OWNED, 'itch.io (my purchases)', 'Owned on itch.io'))
|
||||
self.assertNotIn(KEY, json.dumps(result))
|
||||
|
||||
def test_owned_games_show_under_either_vr_choice(self):
|
||||
# itch.io doesn't say which purchases are VR, so neither filter hides them; other
|
||||
# games of unknown kind still count as flat.
|
||||
self.connect()
|
||||
both = ['Free Quest Toy', 'Orbit Garden VR']
|
||||
for vr in (True, False):
|
||||
names = sorted(a['name'] for a in search.search('', vr=vr, source=itch.OWNED)['apps'])
|
||||
self.assertEqual(names, both)
|
||||
flat = {'source': 'x', 'id': '1', 'name': 'Flat', 'summary': '', 'vr': None}
|
||||
self.assertEqual(search.group([flat], vr=True), [])
|
||||
self.assertEqual(len(search.group([flat], vr=False)), 1)
|
||||
self.assertEqual(search.group([dict(flat, owned=True, vr=False)], vr=True), [])
|
||||
|
||||
def test_only_the_account_source_may_mark_paid_games_owned(self):
|
||||
entry = {'free': False, 'owned': True}
|
||||
self.assertFalse(search.obtainable({'id': 'fdroid'}, entry))
|
||||
self.assertTrue(search.obtainable({'id': itch.OWNED, 'owned': True}, entry))
|
||||
self.assertTrue(search.obtainable({'id': 'fdroid'}, {'free': True}))
|
||||
|
||||
def test_android_upload_selection(self):
|
||||
uploads = json.loads((FIX / 'uploads.json').read_text())['uploads']
|
||||
# .apk files before other Android files, full games before demos, newest first; never
|
||||
# soundtracks, externally hosted files or other platforms.
|
||||
self.assertEqual([u['id'] for u in account.android_uploads(uploads)], [503, 502, 504, 505])
|
||||
self.assertTrue(account.is_android({'filename': 'Game.APK'}))
|
||||
self.assertTrue(account.is_android({'filename': 'game.zip', 'p_android': True}))
|
||||
self.assertTrue(account.is_android({'filename': 'game.zip', 'platforms': {'android': 'all'}}))
|
||||
self.assertFalse(account.is_android({'filename': 'game.zip', 'traits': ['p_windows']}))
|
||||
self.assertEqual(account.android_uploads({'not': 'a list'}), [])
|
||||
|
||||
def test_details_offer_the_android_uploads(self):
|
||||
self.connect()
|
||||
entry = itch.details(self.owned_source(), '341190')
|
||||
self.assertTrue(entry['downloadable'])
|
||||
self.assertEqual([v['upload_id'] for v in entry['versions']], [503, 502, 504, 505])
|
||||
self.assertEqual((entry['version'], entry['size'], entry['updated']), ('Quest 1.2', 115343360, '2026-04-03'))
|
||||
self.assertTrue(entry['versions'][2]['demo'])
|
||||
url = self.web.urls('api.itch.io/games/341190/uploads')[0]
|
||||
self.assertEqual(url, 'https://api.itch.io/games/341190/uploads?download_key_id=9001')
|
||||
with self.assertRaisesRegex(SourceError, 'not among'):
|
||||
itch.details(self.owned_source(), '3') # owned, but not for Android
|
||||
self.web.route('api.itch.io/games/77/uploads', {'uploads': []})
|
||||
self.assertFalse(itch.details(self.owned_source(), '77')['downloadable'])
|
||||
with self.assertRaisesRegex(SourceError, 'no Android download'):
|
||||
itch.download(self.owned_source(), '77')
|
||||
|
||||
def test_download_url_building(self):
|
||||
self.assertEqual(account.download_url(503, 9001),
|
||||
'https://api.itch.io/uploads/503/download?download_key_id=9001')
|
||||
for bad in [(0, 1), (1, None), ('5', 1), (True, 1), (-2, 1)]:
|
||||
with self.assertRaises(SourceError):
|
||||
account.download_url(*bad)
|
||||
|
||||
# -- downloads -----------------------------------------------------------------
|
||||
|
||||
def storage(self, location='https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'):
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 302, b'', {'Location': location}))
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk', Reply(location, 200, apk_bytes()))
|
||||
|
||||
def test_download_sends_the_key_to_itch_only_and_strips_it_on_redirect(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
result = itch.download(self.owned_source(), '341190')
|
||||
self.assertTrue(os.path.isfile(result['apk']))
|
||||
self.assertFalse(result['verified'])
|
||||
api, cdn = self.web.calls[-2:]
|
||||
self.assertEqual(api[0], 'https://api.itch.io/uploads/503/download?download_key_id=9001')
|
||||
self.assertEqual(api[1]['authorization'], 'Bearer ' + KEY)
|
||||
self.assertTrue(cdn[0].startswith('https://storage.itch-cdn.example/'))
|
||||
self.assertNotIn('authorization', cdn[1])
|
||||
# Connected to the address that was checked, not to a fresh lookup.
|
||||
self.assertEqual(self.connected, [('storage.itch-cdn.example', ('151.101.1.1', 443))])
|
||||
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
|
||||
with self.assertRaisesRegex(SourceError, 'version codes'):
|
||||
itch.download(self.owned_source(), '341190', version_code=3)
|
||||
|
||||
def test_download_also_accepts_a_json_url_or_the_file_itself(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
location = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, {'url': location},
|
||||
{'Content-Type': 'application/json'}))
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertNotIn('authorization', self.web.calls[-1][1])
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, apk_bytes(),
|
||||
{'Content-Type': 'application/octet-stream'}))
|
||||
self.connected.clear()
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual(self.connected, []) # api.itch.io served it; no storage host involved
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200, {'nothing': 1},
|
||||
{'Content-Type': 'application/json'}))
|
||||
with self.assertRaisesRegex(SourceError, 'did not say where'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_redirects_are_checked_on_every_hop(self):
|
||||
self.connect()
|
||||
self.storage('https://other-cdn.example/hop')
|
||||
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
|
||||
{'Location': 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk'}))
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example', 'storage.itch-cdn.example'])
|
||||
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
|
||||
{'Location': 'https://intranet.example/a.apk'}))
|
||||
with self.assertRaisesRegex(SourceError, 'private network address'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_redirects_to_plain_http_or_private_addresses_are_refused(self):
|
||||
self.connect()
|
||||
for location in ('http://storage.itch-cdn.example/a.apk', 'https://user:pw@cdn.example/a.apk',
|
||||
'https://cdn.example:8443/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'unexpected download URL'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
for location in ('https://localhost./a.apk', 'https://frame.local/a.apk', 'https://127.0.0x0.1/a.apk',
|
||||
'https://intranet.example/a.apk', 'https://mapped.example/a.apk',
|
||||
'https://cgnat.example/a.apk', 'https://mixed.example/a.apk',
|
||||
'https://127.0.0.1/a.apk', 'https://[::1]/a.apk', 'https://169.254.169.254/a.apk',
|
||||
'https://nat64.example/a.apk', 'https://[4000::1]/a.apk', 'https://[64:ff9b:1::a00:1]/a.apk',
|
||||
'https://[fec0::1]/a.apk', 'https://sitelocal.example/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'private network address'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual(self.connected, [])
|
||||
self.assertEqual({urllib.parse.urlsplit(u).hostname for u, _ in self.web.calls}, {'api.itch.io'})
|
||||
|
||||
def test_public_address_rules(self):
|
||||
for ip in ('151.101.1.1', '8.8.8.8', '2a04:4e42::81', '2001:4860:4860::8888'):
|
||||
self.assertTrue(_web.public_address(ip), ip)
|
||||
for ip in ('127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.0.1', '169.254.169.254', '100.64.0.1',
|
||||
'0.0.0.0', '224.0.0.1', '255.255.255.255', '240.0.0.1', '::1', '::', 'fe80::1%en0', 'fc00::1',
|
||||
'ff02::1', 'not an ip',
|
||||
'192.0.0.8', '192.0.0.11', '192.0.0.255', '192.0.0.170', '192.0.0.9', '192.0.0.1', # 3.9: global
|
||||
'192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9',
|
||||
'0.1.2.3', '10.255.255.255', '172.31.0.1', '239.1.1.1', '254.0.0.1',
|
||||
'4000::1', '100::1', 'f000::1', # reserved: Python 3.9's is_global says True for 4000::1
|
||||
'::ffff:10.0.0.1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', # IPv4-mapped
|
||||
'::a00:1', '::8.8.8.8', # IPv4-compatible
|
||||
'64:ff9b::a00:1', '64:ff9b::808:808', # NAT64 well-known prefix
|
||||
'64:ff9b:1::a00:1', '64:ff9b:1::808:808', # NAT64 local-use: 3.9's is_global says True
|
||||
'2002:a00:1::1', '2002:808:808::1', # 6to4
|
||||
'2001:0:4136:e378:8000:63bf:3fff:fdd2', # Teredo
|
||||
'fec0::1', 'feff::1', # site-local: Python 3.9's is_global says True
|
||||
'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff00::1', 'ff0e::1', '8000::1', 'c000::1',
|
||||
'2001:db8::1', '3fff::1', # documentation
|
||||
'2001:10::1', '2001:20::1', '2001:2::1'): # ORCHID, ORCHIDv2, benchmarking
|
||||
self.assertFalse(_web.public_address(ip), ip)
|
||||
self.assertEqual(_web.resolve_public('storage.itch-cdn.example'), [('151.101.1.1', 443)])
|
||||
self.assertEqual(_web.resolve_public('dual.example'), [('2a04:4e42::81', 443), ('151.101.1.1', 443)])
|
||||
|
||||
def test_a_storage_url_carrying_the_key_is_refused(self):
|
||||
self.connect()
|
||||
for location in ('https://storage.itch-cdn.example/a.apk?api_key=' + KEY,
|
||||
'https://storage.itch-cdn.example/a.apk?API_KEY=someone-elses',
|
||||
'https://storage.itch-cdn.example/a.apk?apikey=x',
|
||||
'https://storage.itch-cdn.example/' + KEY + '/a.apk',
|
||||
'https://storage.itch-cdn.example/a.apk#' + KEY[:10],
|
||||
'https://storage.itch-cdn.example/a.apk?x=' + urllib.parse.quote('%' + KEY),
|
||||
'https://' + KEY.lower() + '.itch-cdn.example/a.apk'):
|
||||
self.storage(location)
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIn(KEY.lower(), str(caught.exception).lower())
|
||||
self.web.route('api.itch.io/uploads/503/download',
|
||||
Reply('https://api.itch.io/uploads/503/download', 200,
|
||||
{'url': 'https://storage.itch-cdn.example/a.apk?api_key=' + KEY},
|
||||
{'Content-Type': 'application/json'}))
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual(self.connected, [])
|
||||
# A storage URL's own signature or token parameters are fine.
|
||||
self.storage('https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?token=abc&Signature=x')
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
|
||||
def test_a_later_redirect_carrying_the_key_is_refused_before_connecting(self):
|
||||
self.connect()
|
||||
bad = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?api_key=' + KEY
|
||||
for hops in (['https://other-cdn.example/hop1'], # 2nd hop carries the key
|
||||
['https://other-cdn.example/hop1', 'https://other-cdn.example/hop2']): # 3rd hop
|
||||
self.connected.clear()
|
||||
self.storage(hops[0])
|
||||
for here, there in zip(hops, hops[1:] + [bad]):
|
||||
path = urllib.parse.urlsplit(here).path
|
||||
self.web.route('other-cdn.example' + path, Reply(here, 302, b'', {'Location': there}))
|
||||
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example'] * len(hops))
|
||||
self.assertFalse(any(KEY in u for u, _ in self.web.calls))
|
||||
# The check runs on every hop before the name is even looked up.
|
||||
seen = []
|
||||
with patch.object(_web, 'resolve_public', side_effect=AssertionError('looked up')):
|
||||
with self.assertRaisesRegex(SourceError, 'no thanks'):
|
||||
_web.public_apk('https://storage.itch-cdn.example/a.apk', 'itch.io',
|
||||
check=lambda u: seen.append(u) or (_ for _ in ()).throw(SourceError('no thanks')))
|
||||
self.assertEqual(seen, ['https://storage.itch-cdn.example/a.apk'])
|
||||
|
||||
def test_a_partial_match_before_the_key_cannot_hide_its_start(self):
|
||||
key = 'abcdefghABCDEFGHi1234567890'
|
||||
for text in (key[:8].upper() + key.upper(), key[:9] + key, 'xx' + key[:8] + key + 'yy'):
|
||||
out = account._clean(text, key)
|
||||
self.assertNotIn('1234567890', out, (text, out))
|
||||
import time
|
||||
t0 = time.monotonic(); account._clean(('abcdefgh' * 12500)[:100000], key)
|
||||
self.assertLess(time.monotonic() - t0, 2.0)
|
||||
|
||||
def test_redaction_catches_every_spelling_of_the_key(self):
|
||||
hexed = ''.join('%%%02X' % ord(c) for c in KEY)
|
||||
for spelling in (KEY, KEY.lower(), KEY.upper(), hexed, hexed.lower(),
|
||||
KEY[:5] + hexed[15:30] + KEY[10:], # partly encoded
|
||||
KEY[:9], hexed[:30].lower(), # starts of it, 8+ characters
|
||||
''.join('%%%02x' % ord(c) for c in KEY.lower()), # encoded after a case change
|
||||
''.join('%%%02X' % ord(c) for c in KEY.swapcase()),
|
||||
'%%%02x' % ord(KEY[0].swapcase()) + KEY[1:]): # one encoded, recased letter
|
||||
cleaned = account._clean('before ' + spelling + ' after', KEY)
|
||||
self.assertEqual(cleaned, 'before [API key] after', spelling)
|
||||
double = ''.join('%%25%02X' % ord(c) for c in KEY) # percent-encoded twice
|
||||
for spelling in (double, double.lower(), double.upper(), double[:5 * 9], # full, hex recased, 9-char start
|
||||
''.join('%%25%02x' % ord(c) for c in KEY.lower()), # the key recased, then double-encoded
|
||||
KEY[:4] + double[20:45] + hexed[27:33] + KEY[11:]): # literal, double and single mixed
|
||||
self.assertEqual(account._clean('before ' + spelling + ' after', KEY), 'before [API key] after', spelling)
|
||||
self.assertEqual(account._clean(double[:5 * 7], KEY), double[:5 * 7]) # 7 characters: kept
|
||||
self.assertTrue(account._carries_key('https://cdn.example/a?x=' + double, KEY))
|
||||
self.assertTrue(account._carries_key('https://cdn.example/' + double[:5 * 8].lower() + '/a', KEY))
|
||||
self.assertEqual(account._clean('TESTKEY is short', KEY), 'TESTKEY is short') # 7 characters: kept
|
||||
self.assertEqual(account._clean(KEY + KEY.lower(), KEY), '[API key][API key]')
|
||||
for url in ('https://cdn.example/a?x=' + hexed, 'https://cdn.example/a?x=' + hexed.lower(),
|
||||
'https://cdn.example/' + KEY.upper() + '/a', 'https://cdn.example/a?x=' + urllib.parse.quote_plus(KEY)):
|
||||
self.assertTrue(account._carries_key(url, KEY), url)
|
||||
self.assertFalse(account._carries_key('https://cdn.example/a?token=abc&Signature=x', KEY))
|
||||
# Storage errors carrying an encoded or lowercased key lose it, keeping their kind.
|
||||
self.connect()
|
||||
self.storage()
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow ' + hexed.lower(), 42)):
|
||||
with self.assertRaises(SourceLimited) as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow [API key]', 42))
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceError('host ' + KEY.lower() + '.cdn')):
|
||||
with self.assertRaisesRegex(SourceError, r'^host \[API key\]\.cdn$'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_errors_lose_the_key_but_keep_their_kind(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow down ' + KEY, 90)):
|
||||
with self.assertRaises(SourceLimited) as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow down [API key]', 90))
|
||||
with patch.object(_web, 'public_apk', side_effect=SourceError('Could not find the download server ' + KEY)):
|
||||
with self.assertRaisesRegex(SourceError, r'^Could not find the download server \[API key\]$') as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(caught.exception.__suppress_context__)
|
||||
|
||||
def test_broken_http_responses_are_readable_and_lose_the_key(self):
|
||||
self.connect()
|
||||
|
||||
class Truncated(Reply):
|
||||
def read(self, *args):
|
||||
raise http.client.IncompleteRead(b'{"owned_keys": [' + KEY.encode(), 1000)
|
||||
cases = [http.client.BadStatusLine('HTTP/1.1 ' + KEY + ' oops'),
|
||||
lambda req, q: Truncated(req.full_url, 200, b''),
|
||||
http.client.RemoteDisconnected('closed after ' + KEY)]
|
||||
for case in cases:
|
||||
account._forget()
|
||||
self.web.route('api.itch.io/profile/owned-keys', case)
|
||||
with self.assertRaisesRegex(SourceError, '^Could not reach itch.io: ') as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(caught.exception.__suppress_context__)
|
||||
self.assertTrue(account.connected())
|
||||
# The same on the download request, for each kind of answer it reads.
|
||||
for case in (http.client.BadStatusLine('HTTP/1.1 ' + KEY),
|
||||
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/json'}),
|
||||
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/octet-stream'})):
|
||||
self.web.route('api.itch.io/uploads/503/download', case)
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
account.download(503, 9001)
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertRegex(str(caught.exception), '^Could not (reach itch.io|download APK): ')
|
||||
|
||||
def test_connecting_shares_one_deadline_across_addresses(self):
|
||||
clock, deadlines = [1000.0], []
|
||||
|
||||
def slow(host, address, timeout, deadline):
|
||||
deadlines.append(deadline)
|
||||
clock[0] += 7 # each address hangs for 7 s before failing
|
||||
raise socket.timeout('timed out')
|
||||
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
|
||||
with self.assertRaises(OSError):
|
||||
_web._public_connection('many.example', 60)
|
||||
self.assertEqual(len(set(deadlines)), 1) # one deadline for every attempt
|
||||
self.assertEqual(deadlines[0], 1000.0 + _web.CONNECT_BUDGET)
|
||||
self.assertEqual(len(deadlines), 3) # 8 addresses, but only 21 s of trying, not 56
|
||||
self.assertLessEqual(clock[0] - 1000.0, _web.CONNECT_BUDGET + 7)
|
||||
deadlines.clear()
|
||||
clock[0] = 1000.0
|
||||
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
|
||||
with self.assertRaises(OSError):
|
||||
_web._public_connection('many.example', 5) # the caller's shorter timeout wins
|
||||
self.assertEqual(deadlines, [1005.0])
|
||||
|
||||
def test_a_stalled_handshake_stops_at_the_deadline(self):
|
||||
# A local server that accepts and then says nothing: the TLS handshake never completes.
|
||||
server = socket.socket()
|
||||
server.bind(('127.0.0.1', 0))
|
||||
server.listen(1)
|
||||
self.addCleanup(server.close)
|
||||
timeouts = []
|
||||
real_settimeout = socket.socket.settimeout
|
||||
|
||||
def settimeout(sock, value):
|
||||
timeouts.append(value)
|
||||
return real_settimeout(sock, value)
|
||||
start = time.monotonic()
|
||||
# No thread is left behind to shut the connection later: the deadline is only socket timeouts.
|
||||
with patch.object(_web.threading, 'Timer', side_effect=AssertionError('watchdog thread')), \
|
||||
patch.object(socket.socket, 'settimeout', settimeout):
|
||||
with self.assertRaises(OSError):
|
||||
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, start + 0.5)
|
||||
self.assertLess(time.monotonic() - start, 3)
|
||||
self.assertTrue(timeouts and all(0 < t <= 0.5 for t in timeouts), timeouts) # what's left, each step
|
||||
with patch.object(_web.socket, 'create_connection', side_effect=AssertionError('connected')):
|
||||
with self.assertRaises(socket.timeout): # a deadline already gone: no connection attempt at all
|
||||
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, time.monotonic() - 1)
|
||||
|
||||
def test_an_unreachable_address_falls_back_to_the_next_checked_one(self):
|
||||
self.connect()
|
||||
self.storage('https://dual.example/upload/503/OrbitGarden-Quest.apk')
|
||||
self.web.route('dual.example/upload/503/OrbitGarden-Quest.apk', Reply('https://dual.example/', 200, apk_bytes()))
|
||||
self.unreachable.add('2a04:4e42::81')
|
||||
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
|
||||
self.assertEqual(self.connected, [('dual.example', ('2a04:4e42::81', 443)), ('dual.example', ('151.101.1.1', 443))])
|
||||
self.unreachable.add('151.101.1.1')
|
||||
with self.assertRaisesRegex(SourceError, '^Could not download APK: Network is unreachable$'):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_storage_refusals_are_download_errors_not_rate_limits(self):
|
||||
self.connect()
|
||||
self.storage()
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
|
||||
Reply('https://storage.itch-cdn.example/x', 403, b'denied'))
|
||||
with self.assertRaisesRegex(SourceError, r"itch.io's download server refused the file \(HTTP 403\)") as caught:
|
||||
itch.download(self.owned_source(), '341190')
|
||||
self.assertNotIsInstance(caught.exception, SourceLimited)
|
||||
self.assertTrue(account.connected())
|
||||
self.assertEqual(_web.wait_time('https://api.itch.io/'), 0)
|
||||
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
|
||||
Reply('https://storage.itch-cdn.example/x', 429, b'', {'Retry-After': '60'}))
|
||||
with self.assertRaises(SourceLimited):
|
||||
itch.download(self.owned_source(), '341190')
|
||||
|
||||
def test_a_key_revoked_after_details_were_cached_is_removed_at_download(self):
|
||||
self.connect()
|
||||
itch.details(self.owned_source(), '341190') # uploads and owned keys now cached
|
||||
for code in (401, 403):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', code, b''))
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', code, b''))
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
|
||||
account.download(503, 9001)
|
||||
self.assertFalse(account.path().exists())
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
|
||||
# The key still works, only this download is refused: keep the key, say so.
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', 403, b''))
|
||||
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
|
||||
account.download(503, 9001)
|
||||
self.assertNotIsInstance(caught.exception, (account.BadKey, SourceLimited))
|
||||
self.assertTrue(account.connected())
|
||||
|
||||
def test_a_disconnect_during_the_key_check_wins(self):
|
||||
checking, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow_profile(req, query):
|
||||
checking.set()
|
||||
release.wait(5)
|
||||
return {'user': {'username': 'player'}}
|
||||
self.web.route('api.itch.io/profile', slow_profile)
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
outcome['result'] = account.connect(KEY)
|
||||
except SourceError as e:
|
||||
outcome['error'] = e
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(checking.wait(5))
|
||||
account.disconnect()
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertRegex(str(outcome.get('error')), 'changed while the key was being checked; nothing was saved')
|
||||
self.assertFalse(account.path().exists())
|
||||
self.assertFalse(account.connected())
|
||||
|
||||
def test_an_old_refusal_does_not_undo_a_reconnect_with_the_same_key(self):
|
||||
self.connect()
|
||||
listing, release, profile_ok = threading.Event(), threading.Event(), [True]
|
||||
|
||||
def slow_owned(req, query):
|
||||
listing.set()
|
||||
release.wait(5)
|
||||
return Reply(req.full_url, 401, b'')
|
||||
self.web.route('api.itch.io/profile/owned-keys', slow_owned)
|
||||
self.web.route('api.itch.io/profile', lambda req, q: {'user': {'username': 'player'}} if profile_ok[0]
|
||||
else Reply(req.full_url, 401, b''))
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
account.owned_keys()
|
||||
except SourceError as e:
|
||||
outcome['error'] = e
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(listing.wait(5))
|
||||
account.disconnect()
|
||||
account.connect(KEY) # same key, connected again while the old listing waits
|
||||
profile_ok[0] = False # the old listing's confirmation now sees a refusal
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertIsInstance(outcome.get('error'), account.BadKey)
|
||||
self.assertTrue(account.path().exists()) # the fresh connection survives
|
||||
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
|
||||
|
||||
def test_redacted_text_is_not_mistaken_for_a_key_error(self):
|
||||
self.connect()
|
||||
before = len(self.web.urls('api.itch.io/profile'))
|
||||
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['Invalid request: ' + KEY]})
|
||||
with self.assertRaisesRegex(SourceError, r'^itch.io: Invalid request: \[API key\]$') as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
self.assertEqual(len(self.web.urls('api.itch.io/profile')), before) # not even a key check needed
|
||||
|
||||
def test_an_invalid_key_error_elsewhere_is_confirmed_before_deleting(self):
|
||||
self.connect()
|
||||
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['invalid key']})
|
||||
with self.assertRaisesRegex(SourceError, '^itch.io: invalid key$') as caught:
|
||||
account.owned_keys() # /profile still accepts the key: keep it
|
||||
self.assertNotIsInstance(caught.exception, account.BadKey)
|
||||
self.assertTrue(account.connected())
|
||||
account._forget()
|
||||
self.web.route('api.itch.io/profile', {'errors': ['invalid key']})
|
||||
with self.assertRaisesRegex(account.BadKey, 'no longer accepts'):
|
||||
account.owned_keys()
|
||||
self.assertFalse(account.connected())
|
||||
|
||||
def test_concurrent_connects_never_share_a_temporary_file(self):
|
||||
made = []
|
||||
real = account.tempfile.mkstemp
|
||||
|
||||
def mkstemp(*args, **kwargs):
|
||||
fd, name = real(*args, **kwargs)
|
||||
made.append(name)
|
||||
if os.name == 'posix':
|
||||
self.assertEqual(os.stat(name).st_mode & 0o777, 0o600)
|
||||
return fd, name
|
||||
with patch.object(account.tempfile, 'mkstemp', mkstemp):
|
||||
workers = [threading.Thread(target=lambda k=k: self.swallow(account.connect, k)) for k in (KEY, OTHER) * 3]
|
||||
for w in workers:
|
||||
w.start()
|
||||
for w in workers:
|
||||
w.join(5)
|
||||
self.assertEqual(len(made), len(set(made)))
|
||||
self.assertIn(json.loads(account.path().read_text())['key'], (KEY, OTHER))
|
||||
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json'])
|
||||
|
||||
@staticmethod
|
||||
def swallow(fn, *args):
|
||||
try:
|
||||
fn(*args)
|
||||
except SourceError:
|
||||
pass # a connect overtaken by another one refuses to save: fine here
|
||||
|
||||
def test_existing_hosts_still_strip_authorization_on_redirect(self):
|
||||
req = urllib.request.Request('https://api.github.com/a', headers={'Authorization': 'Bearer x'})
|
||||
nxt = _web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://api.github.com/b')
|
||||
self.assertFalse(nxt.has_header('Authorization'))
|
||||
with self.assertRaises(SourceError):
|
||||
_web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://evil.example/b')
|
||||
|
||||
def test_install_uses_the_normal_apk_install_path(self):
|
||||
import frame_android
|
||||
self.connect()
|
||||
self.storage()
|
||||
calls = []
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
with zipfile.ZipFile(apk_path) as z:
|
||||
self.assertIn('AndroidManifest.xml', z.namelist())
|
||||
calls.append((name, source))
|
||||
return {'package': 'com.maker.orbit', 'message': 'Installed'}
|
||||
with patch.object(frame_android, 'install', install):
|
||||
result = search.install(itch.OWNED, '341190')
|
||||
self.assertEqual(calls, [('Orbit Garden VR', 'itch.io (my purchases)')])
|
||||
self.assertEqual(result['package'], 'com.maker.orbit')
|
||||
|
||||
# -- errors never carry the key ------------------------------------------------
|
||||
|
||||
def test_errors_are_readable_and_never_contain_the_key(self):
|
||||
self.connect()
|
||||
cases = [
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 429, b'', {'Retry-After': '120'}),
|
||||
SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'),
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 503, b''), SourceError, 'HTTP error 503; try again later'),
|
||||
(urllib.error.URLError('connect to api.itch.io with ' + KEY + ' failed'), SourceError,
|
||||
r'^Could not reach itch.io: connect to api.itch.io with \[API key\] failed$'),
|
||||
(OSError('timed out'), SourceError, '^Could not reach itch.io: timed out$'),
|
||||
(Reply('https://api.itch.io/profile/owned-keys', 200, b'<html>'), SourceError, 'invalid response'),
|
||||
({'errors': ['echo ' + KEY]}, SourceError, r'^itch.io: echo \[API key\]$'),
|
||||
# The key straddles the 200-character cut: no piece of it may survive.
|
||||
({'errors': ['x' * 180, KEY]}, SourceError, r'^itch.io: x{180}; \[API key\]$'),
|
||||
({'errors': ['x' * 190 + KEY]}, SourceError, r'^itch.io: x{190}\[API key\]$'),
|
||||
]
|
||||
self.assertEqual(account._clean('cut at ' + KEY[:9], KEY), 'cut at [API key]') # a truncated key's start
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
for reply, kind, message in cases:
|
||||
account._forget()
|
||||
_web._limited.clear()
|
||||
self.web.route('api.itch.io/profile/owned-keys', reply)
|
||||
with self.assertRaisesRegex(kind, message) as caught:
|
||||
account.owned_keys()
|
||||
self.assertNotIn(KEY, str(caught.exception))
|
||||
self.assertIsNone(caught.exception.__cause__)
|
||||
self.assertTrue(account.connected()) # not the key's fault: stays connected
|
||||
# The store's search reports the failure as a non-fatal source status.
|
||||
account._forget()
|
||||
result = search.search('', source=itch.OWNED)
|
||||
self.assertEqual(result['sources'][0]['status'], 'error')
|
||||
self.assertNotIn(KEY, json.dumps(result) + stderr.getvalue())
|
||||
|
||||
def test_server_api_connects_and_never_echoes_the_key(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
|
||||
def request(method, path, body=None):
|
||||
c = http.client.HTTPConnection('127.0.0.1', httpd.server_port)
|
||||
c.request(method, path, json.dumps(body) if body is not None else None,
|
||||
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
|
||||
r = c.getresponse()
|
||||
out = r.status, r.read().decode()
|
||||
c.close()
|
||||
return out
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
|
||||
bad = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
|
||||
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
|
||||
good = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
|
||||
listed = request('GET', '/api/sources')
|
||||
empty = request('POST', '/api/sources', {'action': 'itch-connect', 'key': ' '})
|
||||
gone = request('POST', '/api/sources', {'action': 'itch-disconnect'})
|
||||
self.assertEqual(bad[0], 400)
|
||||
self.assertIn('did not accept the API key', json.loads(bad[1])['error'])
|
||||
self.assertEqual(good[0], 200)
|
||||
self.assertEqual(json.loads(good[1])['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertEqual(json.loads(listed[1])['itch'], {'connected': True, 'username': 'player'})
|
||||
self.assertIn(itch.OWNED, [s['id'] for s in json.loads(listed[1])['sources']])
|
||||
self.assertEqual(empty[0], 400)
|
||||
self.assertEqual(json.loads(gone[1])['itch'], {'connected': False, 'username': None})
|
||||
self.assertFalse(account.path().exists())
|
||||
for text in (bad[1], good[1], listed[1], empty[1], gone[1], stderr.getvalue()):
|
||||
self.assertNotIn(KEY, text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -84,7 +84,10 @@ 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):
|
||||
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
|
||||
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)
|
||||
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', 'loadBackups', '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)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,520 @@
|
||||
"""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.
|
||||
|
||||
The encoding tests need x264, which only CI's own builds bundle (it's GPL, so
|
||||
the installer leaves it out). FRAME_PC_REQUIRE_X264=1 makes its absence a
|
||||
failure; FRAME_PC_INSTALLER_BUNDLE=1 checks the installer's bundle instead.
|
||||
"""
|
||||
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 GatingTests(unittest.TestCase):
|
||||
"""Experimental, Windows only, and off until the person turns it on."""
|
||||
|
||||
def view(self):
|
||||
return frame_pcview.PCView(['ssh'], mock.Mock(), 'frame')
|
||||
|
||||
def test_only_windows_gets_the_pc_host(self):
|
||||
for platform, cls in (('win32', frame_pcview.PCView), ('linux', frame_macview.MacView),
|
||||
('darwin', frame_macview.MacView)):
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', platform):
|
||||
view = frame_pcview.host_view(['ssh'], mock.Mock(), 'frame')
|
||||
self.assertIs(type(view), cls, platform)
|
||||
|
||||
def test_off_by_default_starts_nothing(self):
|
||||
view = self.view()
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_macview.subprocess, 'Popen', side_effect=AssertionError('started while off')):
|
||||
state = view.state()
|
||||
self.assertEqual(state, {'available': False, 'reason': frame_pcview.OFF, 'host': view.host,
|
||||
'experimental': True, 'enabled': False})
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
view.show('window:12')
|
||||
view.run.assert_not_called()
|
||||
|
||||
def test_never_offered_off_windows_even_when_turned_on(self):
|
||||
view = self.view()
|
||||
view.enabled = True
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'linux'):
|
||||
self.assertIn('only available on Windows', view.unavailable())
|
||||
|
||||
def test_turning_on_checks_for_the_bundled_libraries(self):
|
||||
view = self.view()
|
||||
missing = mock.Mock(is_file=mock.Mock(return_value=False))
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(frame_pcview, 'LIBRARY', missing):
|
||||
state = view.set_enabled(True)
|
||||
self.assertTrue(state['enabled'])
|
||||
self.assertFalse(state['available'])
|
||||
self.assertIn('libraries are missing', state['reason'])
|
||||
|
||||
def test_turning_off_stops_streams_and_the_helper(self):
|
||||
view = self.view()
|
||||
view.enabled = True
|
||||
agent = mock.Mock()
|
||||
agent.poll.return_value = None
|
||||
view.agent, view.tunnel = agent, mock.Mock()
|
||||
tunnel = view.tunnel
|
||||
tunnel.poll.return_value = None
|
||||
view.stop = mock.Mock()
|
||||
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'):
|
||||
state = view.set_enabled(False)
|
||||
view.stop.assert_called_once_with()
|
||||
agent.stdin.close.assert_called_once()
|
||||
agent.wait.assert_called()
|
||||
tunnel.terminate.assert_called_once()
|
||||
self.assertIsNone(view.agent)
|
||||
self.assertEqual((state['enabled'], state['reason']), (False, frame_pcview.OFF))
|
||||
|
||||
def test_server_enable_action(self):
|
||||
import sandbox # noqa: F401 (keeps the server off real data and services)
|
||||
import server
|
||||
with mock.patch.object(server, 'macview', frame_macview.MacView(['ssh'], mock.Mock(), 'frame')):
|
||||
with self.assertRaises(server.Failure) as e:
|
||||
server.macview_action({'action': 'enable', 'on': True})
|
||||
self.assertIn('only available on Windows', str(e.exception))
|
||||
view = self.view()
|
||||
view.set_enabled = mock.Mock(return_value={'enabled': True})
|
||||
with mock.patch.object(server, 'macview', view):
|
||||
self.assertEqual(server.macview_action({'action': 'enable', 'on': True}), {'enabled': True})
|
||||
server.macview_action({'action': 'enable', 'on': 'yes'}) # anything but true is off
|
||||
self.assertEqual([c.args for c in view.set_enabled.call_args_list], [(True,), (False,)])
|
||||
|
||||
|
||||
class PageTests(unittest.TestCase):
|
||||
page = (ROOT / 'ui' / 'index.html').read_text(encoding='utf-8')
|
||||
|
||||
def test_tool_link_is_windows_only_and_marked_experimental(self):
|
||||
self.assertIn('<a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span>'
|
||||
'<small>Experimental:', self.page)
|
||||
self.assertIn('$("pcToolLink").hidden = h.mobile || h.os !== "Windows";', self.page)
|
||||
self.assertIn('$("macToolLink").hidden = h.mobile || h.os !== "macOS";', self.page)
|
||||
|
||||
def test_card_says_experimental_and_what_is_unverified(self):
|
||||
self.assertIn('<span class="tag" id="mvTag" hidden>Experimental</span>', self.page)
|
||||
self.assertIn('<input type="checkbox" id="mvPcOn">', self.page)
|
||||
self.assertIn("hasn't been tried on a real\n Windows desktop or with a headset yet", self.page)
|
||||
self.assertIn('docs/pc-in-headset.md', self.page)
|
||||
|
||||
def test_opt_in_is_remembered_with_the_client_preferences(self):
|
||||
self.assertIn("const keys = ['mvSeparate', 'padUsed', 'pcView']", self.page)
|
||||
prefs = (ROOT / 'app' / 'preferences.js').read_text(encoding='utf-8')
|
||||
self.assertIn('const KEYS = ["mvSeparate", "padUsed", "pcView"];', prefs)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def x264_bundled():
|
||||
return any((capture.NATIVE / 'lib' / 'gstreamer-1.0').glob('*gstx264*'))
|
||||
|
||||
|
||||
def needs_x264(test):
|
||||
def run(self):
|
||||
if not x264_bundled():
|
||||
if os.environ.get('FRAME_PC_REQUIRE_X264') == '1':
|
||||
raise AssertionError('CI required x264 in the native PC bundle (desktop/build.py --with-x264)')
|
||||
raise unittest.SkipTest('x264 is only in CI test bundles, not the installer')
|
||||
return test(self)
|
||||
run.__name__, run.__doc__ = test.__name__, test.__doc__
|
||||
return run
|
||||
|
||||
|
||||
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_agent_reports_its_host_and_encoder(self):
|
||||
self.assertEqual(self.request('/status')[0], 403)
|
||||
status, body = self.request('/status?k='+self.token)
|
||||
self.assertEqual(status, 200)
|
||||
data = json.loads(body)
|
||||
if sys.platform == 'win32':
|
||||
self.assertEqual((data['host'], data['encoder']), ('windows', 'mfh264enc'))
|
||||
self.assertEqual(data['streams'], [])
|
||||
|
||||
@unittest.skipUnless(os.environ.get('FRAME_PC_INSTALLER_BUNDLE') == '1', 'checks the installer\'s bundle')
|
||||
def test_installer_bundle_leaves_out_gpl_x264(self):
|
||||
self.assertFalse(x264_bundled())
|
||||
self.assertFalse(list((capture.NATIVE / 'bin').glob('libx264*')))
|
||||
plugins = [p.name for p in (capture.NATIVE / 'lib' / 'gstreamer-1.0').iterdir()]
|
||||
for name in ('mediafoundation', 'd3d11', 'jpeg'):
|
||||
self.assertTrue(any(p.endswith('gst%s.dll' % name) for p in plugins), (name, plugins))
|
||||
|
||||
@needs_x264
|
||||
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)
|
||||
|
||||
@needs_x264
|
||||
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"):
|
||||
@@ -103,6 +109,10 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/record")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/clips")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/record", {"action": "start"})[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})[0], 403)
|
||||
|
||||
def test_captures_are_not_cacheable(self):
|
||||
# Headset captures show everything on screen; nothing may cache them.
|
||||
@@ -128,11 +138,24 @@ class ServerGuards(unittest.TestCase):
|
||||
("/api/shots/save", {"ids": [1]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
|
||||
("/api/record", {}),
|
||||
("/api/record", {"action": "explode"}),
|
||||
("/api/clips/export", {}),
|
||||
("/api/clips/export", {"id": 5}),
|
||||
("/api/clips/export", {"id": "1/video/../../../../.ssh"}),
|
||||
("/api/clips/export", {"id": "1/video/bg;rm -rf ~"}),
|
||||
("/api/clips/export", {"id": "../1/video/bg"}),
|
||||
]
|
||||
for path, body in cases:
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_recorder_idle_until_started(self):
|
||||
status, _, payload = self.request("GET", "/api/record", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual((status, json.loads(payload)), (200, {"state": "idle"}))
|
||||
status, payload = self.post("/api/record", {"action": "stop"})
|
||||
self.assertEqual(status, 409, payload)
|
||||
|
||||
def test_showing_a_shot_needs_it_saved_here(self):
|
||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
||||
self.assertEqual(status, 404, payload)
|
||||
@@ -524,6 +547,14 @@ class LocalMode(unittest.TestCase):
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIsInstance(body["titles"], list)
|
||||
|
||||
def test_recordings_are_for_a_computer(self):
|
||||
for method, path, body in (("GET", "/api/record", None), ("GET", "/api/clips", None),
|
||||
("POST", "/api/record", {"action": "start"}),
|
||||
("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})):
|
||||
status, payload = self.request(method, path, body)
|
||||
self.assertEqual(status, 400, (path, payload))
|
||||
self.assertIn("computer", payload["error"])
|
||||
|
||||
def test_open_is_for_the_app_and_power_needs_a_password(self):
|
||||
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
|
||||
status, body = self.request("POST", "/api/open", {"what": "reboot"})
|
||||
|
||||
@@ -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,14 @@
|
||||
"""Type on the headset: entry points, the T key and the clipboard copy, without a browser or headset."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the page code')
|
||||
class TypingUi(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/typing_ui.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -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,114 @@
|
||||
// Type on the headset: where people find it, the T key, and copying the headset's clipboard.
|
||||
// Production page code with only browser and transport boundaries replaced; no headset.
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const cc=require('../ui/control-center.js');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
|
||||
const between=(from,to)=>{const start=html.indexOf(from),end=html.indexOf(to,start);assert.ok(start>=0&&end>start,from);return html.slice(start,end);};
|
||||
const section=id=>between(`id="${id}"`,'</section>');
|
||||
|
||||
test('Home, the Right now card and the Tools directory all name it Type on the headset',()=>{
|
||||
const home=between('data-page="home"','data-page="capture"');
|
||||
assert.match(home,/<a href="#pad" title="Type on the headset \(T\)"><span>Type on the headset<\/span><small>Keyboard, trackpad and clipboard<\/small>/);
|
||||
assert.match(home,/<button data-go="pad" title="Type on the headset \(T\)">Type on the headset<\/button>/);
|
||||
assert.match(between('id="toolDirectory"','<h2>Your Frame</h2>'),/href="#pad"[^>]*><span>Type on the headset<\/span>/);
|
||||
assert.doesNotMatch(html,/Keyboard &(amp;)? trackpad<\/span>|Type on the Frame</);
|
||||
});
|
||||
test('the clipboard, both ways, lives beside the keyboard, not in Send to Frame',()=>{
|
||||
const pad=section('pad');
|
||||
for(const id of ['clipText','clipSend','clipMac','clipFrom'])assert.match(pad,new RegExp(`id="${id}"`),id);
|
||||
assert.match(pad,/Text only, up to 1 MB/);
|
||||
const transfer=section('transfer');
|
||||
assert.doesNotMatch(transfer,/id="clip/);
|
||||
assert.match(transfer,/<a href="#pad">Type on the headset<\/a>/);
|
||||
});
|
||||
test('the tool page is titled Type on the headset',()=>{
|
||||
const f=workspaceFixture();
|
||||
f.navigate('#pad');
|
||||
assert.equal(f.activePage,'tools');assert.equal(f.el('pad').hidden,false);
|
||||
assert.equal(f.el('toolTitle').textContent,'Type on the headset');
|
||||
assert.equal(f.el('transfer').hidden,true);
|
||||
});
|
||||
|
||||
function keyFixture() {
|
||||
const f=workspaceFixture(),keys=[];let focused=0,started=0;
|
||||
Object.assign(f.context,{pad:{state:'off'},padStart(){started++;},ctrl:{on:false},PAGES:['apps','games','tools','settings'],
|
||||
history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||
f.context.document.addEventListener=(type,fn)=>{if(type==='keydown')keys.push(fn);};
|
||||
f.context.document.querySelector=()=>null;
|
||||
f.context.document.activeElement={tagName:'BODY'};
|
||||
f.el('padType').focus=()=>{focused++;};
|
||||
f.run(between('function showNow(','$("nowSend").onclick'));
|
||||
f.run(between('document.addEventListener("keydown", e => {\n if (e.metaKey || e.ctrlKey || e.altKey','// ---- after an APK install'));
|
||||
const press=(key,extra={})=>{let prevented=false;keys.forEach(fn=>fn({key,preventDefault(){prevented=true;},...extra}));return prevented;};
|
||||
return {f,press,get focused(){return focused;},get started(){return started;}};
|
||||
}
|
||||
test('T opens Type on the headset, turns the keyboard on and puts the cursor in its field',()=>{
|
||||
const k=keyFixture();
|
||||
k.f.navigate('#games');
|
||||
assert.equal(k.press('t'),true,'the T must not also be typed into the field it focuses');
|
||||
assert.equal(k.f.context.location.hash,'#pad');assert.equal(k.f.activePage,'tools');
|
||||
assert.equal(k.started,1);assert.equal(k.focused,1);
|
||||
k.f.context.pad.state='ready';k.press('T');assert.equal(k.started,1,'already on: not started again');
|
||||
});
|
||||
test('T stays out of the way of typing, shortcuts and keys going to the Frame',()=>{
|
||||
const k=keyFixture();
|
||||
k.f.navigate('#games');
|
||||
k.press('t',{metaKey:true});k.press('t',{ctrlKey:true});k.press('t',{altKey:true});
|
||||
k.f.context.document.activeElement={tagName:'INPUT'};k.press('t');
|
||||
k.f.context.document.activeElement={tagName:'BODY'};k.f.context.document.querySelector=s=>s==='dialog[open]'?{}:null;k.press('t');
|
||||
k.f.context.document.querySelector=()=>null;
|
||||
k.f.context.ctrl.on=true;k.f.context.document.activeElement=k.f.el('viewer');k.press('t');
|
||||
assert.equal(k.f.context.location.hash,'#games');assert.equal(k.started,0);
|
||||
k.f.context.document.activeElement={tagName:'BODY'};k.press('3');assert.equal(k.f.context.location.hash,'#tools','number keys still switch pages');
|
||||
});
|
||||
test('Home’s button goes through the same path, showing the tool once before focusing it',()=>{
|
||||
const f=workspaceFixture(),buttons=[{dataset:{go:'pad'}},{dataset:{go:'media'}}];let focusedVisible=null;
|
||||
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||
Object.assign(f.context,{pad:{state:'error'},padStart(){f.calls.push(['padStart']);},history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
|
||||
f.navigate('#home');f.run(between('function showNow(','$("nowSend").onclick'));
|
||||
buttons[0].onclick();
|
||||
assert.equal(focusedVisible,true);assert.equal(f.calls.filter(c=>c[0]==='padStart').length,1);
|
||||
buttons[1].onclick();assert.equal(f.context.location.hash,'#media');
|
||||
});
|
||||
|
||||
function copyFixture(frameApp) {
|
||||
const calls=[],f=workspaceFixture();
|
||||
Object.assign(f.context,{window:{frameApp},api:async(path,body)=>{calls.push(JSON.parse(JSON.stringify([path,body])));return body.read?{text:'héllo 🎧\n',message:'Copied 8 characters from the headset\'s clipboard'}:{message:'Copied to this computer'};}});
|
||||
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
|
||||
return {f,calls};
|
||||
}
|
||||
test('in the app, the headset’s clipboard is read and the app writes it, text untouched',async()=>{
|
||||
const written=[],{f,calls}=copyFixture({writeClipboard:async text=>{written.push(text);return true;}});
|
||||
const result=await f.run('copyHeadsetClipboard()');
|
||||
assert.deepEqual(calls,[['/api/clipboard',{read:true}]]);
|
||||
assert.deepEqual(written,['héllo 🎧\n']);
|
||||
assert.equal(result.message,'Copied 8 characters from the headset\'s clipboard');
|
||||
assert.equal(result.text,undefined,'the text is not handed on to be logged or toasted');
|
||||
});
|
||||
test('in a browser, the server sets the clipboard of the computer it runs on',async()=>{
|
||||
for(const frameApp of [undefined,{readClipboard(){}}]){
|
||||
const {f,calls}=copyFixture(frameApp);
|
||||
assert.equal((await f.run('copyHeadsetClipboard()')).message,'Copied to this computer');
|
||||
assert.deepEqual(calls,[['/api/clipboard',{toComputer:true}]]);
|
||||
}
|
||||
});
|
||||
test('a failed read writes nothing',async()=>{
|
||||
const written=[],f=workspaceFixture();
|
||||
Object.assign(f.context,{window:{frameApp:{writeClipboard:async t=>written.push(t)}},api:async()=>{throw new Error("The headset's clipboard is empty");}});
|
||||
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
|
||||
await assert.rejects(f.run('copyHeadsetClipboard()'),/empty/);
|
||||
assert.deepEqual(written,[]);
|
||||
});
|
||||
|
||||
test('the Control Center finds Type on the headset by typing, keyboard and clipboard words',()=>{
|
||||
const input=cc.catalog.find(item=>item.id==='input');
|
||||
assert.equal(input.title,'Type on the headset');
|
||||
assert.deepEqual(input.links[0],['#pad','Type on the headset']);
|
||||
for(const query of ['type','typing','keyboard','clipboard','copy paste','kde connect'])
|
||||
assert.ok(cc.matches(input,query,'all'),query);
|
||||
const files=cc.catalog.find(item=>item.id==='files');
|
||||
assert.ok(files.links.some(([href])=>href==='#pad'),'Send files points clipboard seekers to the right place');
|
||||
});
|
||||
@@ -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,runningGames:[],runningSeq: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(){},resetClips(){},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');
|
||||
});
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user