mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 12:01:40 +02:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
26206e85b4 | ||
|
|
d7a887b98c | ||
|
|
f4efefa958 | ||
|
|
a5980d8f98 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
810ef1822d | ||
|
|
3dd8e982a3 | ||
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
16b37bc667 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
d546568a11 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
57a9d8c717 | ||
|
|
fa9ee01cc1 | ||
|
|
63017e1969 | ||
|
|
6aab62983e | ||
|
|
bcdd9d0830 | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
d6fb5e917a | ||
|
|
8051687e1e | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 | ||
|
|
daf30515b1 | ||
|
|
2f056dbcff | ||
|
|
e3176a2de8 | ||
|
|
416f3181cc |
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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
# A newer push to the same pull request supersedes a run still in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -17,7 +22,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
- name: Install zsh
|
- name: Install zsh
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||||
|
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||||
|
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||||
- name: Script syntax
|
- name: Script syntax
|
||||||
run: |
|
run: |
|
||||||
sh -n ui/local-bin/ssh
|
sh -n ui/local-bin/ssh
|
||||||
@@ -36,8 +43,8 @@ jobs:
|
|||||||
run: python -m unittest discover -s tests -v
|
run: python -m unittest discover -s tests -v
|
||||||
- name: App syntax
|
- 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
|
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
|
- name: App tests
|
||||||
run: node --test app/test/updater.test.js
|
run: node --test app/test/*.test.js
|
||||||
- name: Website
|
- name: Website
|
||||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||||
|
|
||||||
@@ -84,6 +91,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install zsh
|
- name: Install zsh
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||||
|
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||||
|
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||||
- name: End-to-end tests
|
- name: End-to-end tests
|
||||||
run: scripts/e2e.sh
|
run: scripts/e2e.sh
|
||||||
@@ -2,14 +2,22 @@ name: release
|
|||||||
|
|
||||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
# 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).
|
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||||
# Pull requests that touch the app build the same installers as artifacts.
|
# Pull requests that change how the installers are built (the Electron app, the
|
||||||
|
# macOS helper it compiles, or this workflow) build the same installers as
|
||||||
|
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||||
|
# are tested by the checks workflow; run this one by hand to package them early.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# A newer push to the same pull request supersedes a build still in progress.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
@@ -94,7 +102,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||||
|
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
with:
|
with:
|
||||||
name: frame-control-${{ matrix.os }}
|
name: frame-control-${{ matrix.os }}
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w
|
|||||||
<td valign="top">
|
<td valign="top">
|
||||||
|
|
||||||
**🎮 Steam games**<br>
|
**🎮 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>
|
||||||
<td valign="top">
|
<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>
|
<tr>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
|
|
||||||
**📁 Files, games and clipboard**<br>
|
**📁 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. Send text or your clipboard straight to the headset's desktop.
|
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>
|
||||||
<td valign="top">
|
<td valign="top">
|
||||||
|
|
||||||
**📸 Screenshots**<br>
|
**📸 Screenshots and recordings**<br>
|
||||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
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 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. Nothing else to install, anywhere.
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
|||||||
|
|
||||||
This is a first public test, so reports are really useful, especially from
|
This is a first public test, so reports are really useful, especially from
|
||||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||||
diagnostics with personal details removed, shows you exactly what's included,
|
diagnostics with personal details removed, shows you exactly what's included,
|
||||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||||
@@ -219,12 +219,12 @@ Frame's software fits together, all checked against a real headset and labelled
|
|||||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||||
| [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 |
|
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
| [VR mods and custom songs](docs/mods.md) | 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 |
|
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
+10
@@ -11,6 +11,7 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||||
const updater = require("./updater");
|
const updater = require("./updater");
|
||||||
|
const { preferenceStore } = require("./preferences");
|
||||||
const { createMetaSignIn } = require("./meta-signin");
|
const { createMetaSignIn } = require("./meta-signin");
|
||||||
|
|
||||||
const run = promisify(execFile);
|
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 LOG = path.join(LOG_DIR, "server.log");
|
||||||
const BG = "#0d1117";
|
const BG = "#0d1117";
|
||||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||||
|
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||||
|
|
||||||
let server = null;
|
let server = null;
|
||||||
let url = 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();
|
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||||
});
|
});
|
||||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||||
|
// 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.
|
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.7",
|
"version": "0.4.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.7",
|
"version": "0.4.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.4.7",
|
"version": "0.4.9",
|
||||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"preload.js",
|
"preload.js",
|
||||||
"install-link.js",
|
"install-link.js",
|
||||||
"updater.js",
|
"updater.js",
|
||||||
|
"preferences.js",
|
||||||
"meta-signin.js",
|
"meta-signin.js",
|
||||||
"package.json",
|
"package.json",
|
||||||
"build/icon.png"
|
"build/icon.png"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Client choices live outside the local server's changing URL/origin.
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const KEYS = ["mvSeparate", "padUsed"];
|
||||||
|
const valid = value => value === "0" || value === "1";
|
||||||
|
|
||||||
|
function preferenceStore(file) {
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||||
|
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
function write(key, value) {
|
||||||
|
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||||
|
const saved = { ...read(), [key]: value };
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = file + ".tmp";
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return { read, write };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { preferenceStore };
|
||||||
+7
-2
@@ -1,5 +1,5 @@
|
|||||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// 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
|
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||||
// as a title without zipping it (the local server reads it from there).
|
// as a title without zipping it (the local server reads it from there).
|
||||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
// 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");
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("frameApp", {
|
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),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { preferenceStore } = require("../preferences");
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||||
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
return path.join(dir, "client-preferences.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("desktop packaging includes every local application module dependency", () => {
|
||||||
|
const root = path.resolve(__dirname, "..");
|
||||||
|
const packaged = new Set(require("../package.json").build.files);
|
||||||
|
for (const file of ["main.js", "preload.js"]) {
|
||||||
|
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||||
|
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||||
|
const target = match[1].slice(2) + ".js";
|
||||||
|
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("client choices survive a new native session and local server origin", t => {
|
||||||
|
const file = fixture(t);
|
||||||
|
const first = preferenceStore(file);
|
||||||
|
assert.deepEqual(first.read(), {});
|
||||||
|
first.write("mvSeparate", "0");
|
||||||
|
first.write("padUsed", "1");
|
||||||
|
// A new process/session reads the same client store, regardless of its URL.
|
||||||
|
const second = preferenceStore(file);
|
||||||
|
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||||
|
second.write("mvSeparate", "1");
|
||||||
|
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only the two non-sensitive binary choices can enter the native store", t => {
|
||||||
|
const file = fixture(t), store = preferenceStore(file);
|
||||||
|
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||||
|
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||||
|
}
|
||||||
|
assert.equal(fs.existsSync(file), false);
|
||||||
|
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||||
|
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||||
|
store.write("padUsed", "1");
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing or damaged preference file retains usable defaults", t => {
|
||||||
|
const file = fixture(t), store = preferenceStore(file);
|
||||||
|
assert.deepEqual(store.read(), {});
|
||||||
|
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||||
|
fs.writeFileSync(file, bad);
|
||||||
|
assert.deepEqual(store.read(), {});
|
||||||
|
}
|
||||||
|
store.write("padUsed", "1");
|
||||||
|
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||||
|
});
|
||||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
|||||||
|
|
||||||
## Assistant panel
|
## Assistant panel
|
||||||
|
|
||||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||||
To put the same page in the headset, with the HTTP server still running:
|
To put the same page in the headset, with the HTTP server still running:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -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.
|
||||||
+54
-1
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
|
|||||||
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
||||||
removing and re-adding it.
|
removing and re-adding it.
|
||||||
|
|
||||||
|
### Repositories behind a login
|
||||||
|
|
||||||
|
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
|
||||||
|
client, the login can be part of the address
|
||||||
|
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
|
||||||
|
store's **Add a source** form under "This repository needs a login", in the
|
||||||
|
server API (`username` and `password` beside `url`), or on the command line,
|
||||||
|
where the password is read from standard input so it stays out of shell history
|
||||||
|
and `ps`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
How the login is kept and used:
|
||||||
|
|
||||||
|
- **Kept out of the address.** The `user:pass@` part is removed before anything
|
||||||
|
is saved, so the saved repository URL, its id, the source list, job results
|
||||||
|
and the page never contain it. The store only says "Uses a login".
|
||||||
|
- **Stored separately.** Logins live in
|
||||||
|
`frame_host.data_dir('source-credentials.json')`
|
||||||
|
(`~/Library/Application Support/Frame Control/source-credentials.json` on
|
||||||
|
macOS), written with mode 0600 and keyed by source id. They are stored in
|
||||||
|
plain text there, protected by file permissions only (on Windows,
|
||||||
|
the folder's normal ACLs). Removing the repository deletes its login, and
|
||||||
|
the file goes when the last login is removed. Each change holds a lock
|
||||||
|
file (`source-credentials.lock`), so the app and the command line can't undo
|
||||||
|
each other's saves or removals.
|
||||||
|
- **HTTPS only.** An `http://` address with a login is refused before any request.
|
||||||
|
- **Inside the repository only.** The `Authorization` header is decided
|
||||||
|
separately for every request, redirects included: index, `entry.jar`,
|
||||||
|
APKs and the store's artwork proxy. It is sent only when the request's
|
||||||
|
scheme, host and port match the repository's and its path is inside the
|
||||||
|
repository's directory by whole path segments. A redirect to another host
|
||||||
|
or port, or to another path on the same server, gets no login, and that
|
||||||
|
request's own `Authorization` header is removed. Paths with `.` or `..`
|
||||||
|
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
|
||||||
|
or `;` get no login, since a server could normalise them to a path
|
||||||
|
outside the repository. Index file names like that are refused outright.
|
||||||
|
- **Never echoed.** Logins are not written to logs, error messages, API
|
||||||
|
responses or the cache. The CLI prints only the saved source.
|
||||||
|
- **Checked before saving.** As with any repository, the whole index must
|
||||||
|
download and verify before anything is saved. HTTP 401 (or 403 when a login
|
||||||
|
was sent) stops the add with "check the username and password" and saves
|
||||||
|
nothing: no repository, no login, no rollback state. A 403 with a login is
|
||||||
|
not treated as rate limiting.
|
||||||
|
- **Same verification.** The login changes only who may download. Signature,
|
||||||
|
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
|
||||||
|
any other repository.
|
||||||
|
|
||||||
|
Re-adding the same address with a new login replaces the saved one. Re-adding
|
||||||
|
it without a login keeps the saved one.
|
||||||
|
|
||||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||||
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
|
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
|
||||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||||
This change supplies the CLI and API; the integrated source-management UI is
|
This change supplies the CLI and API; the integrated source-management UI is
|
||||||
|
|||||||
+17
-4
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
|
|||||||
claims an open-source license. This prevents an arbitrary tagged mirror from
|
claims an open-source license. This prevents an arbitrary tagged mirror from
|
||||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||||
|
|
||||||
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
|
For a higher API quota, add a GitHub access token in the store's source
|
||||||
quota. Tokens are sent only to `api.github.com`, never written to the cache,
|
settings (the GitHub row's token field) or through the server API
|
||||||
never sent to asset hosts, and removed on redirects. The adapter does not
|
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
|
||||||
read `gh` credentials automatically. Metadata is cached for one hour under
|
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
|
||||||
|
works and wins over a saved token. A saved token is kept with repository
|
||||||
|
logins in `source-credentials.json` (mode 0600, see
|
||||||
|
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
|
||||||
|
reports only whether a token is set, where it came from and whether GitHub
|
||||||
|
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
|
||||||
|
`api.github.com`, never written to the cache, never sent to asset hosts (public
|
||||||
|
release downloads don't need them), and removed on redirects. If GitHub answers
|
||||||
|
401 to the token, the request is retried once without it, as Obtainium does.
|
||||||
|
The rejection is logged without the token and shown in source settings. The
|
||||||
|
token is not tried again until it is saved again or the app restarts. Rate-limit
|
||||||
|
backoff is kept per login (anonymous, or each token), so saving a token after
|
||||||
|
anonymous requests were limited lets the next request go out with it. The
|
||||||
|
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
|
||||||
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
||||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||||
|
|||||||
@@ -183,6 +183,63 @@ See
|
|||||||
[compat-db/README.md](../compat-db/README.md) and
|
[compat-db/README.md](../compat-db/README.md) and
|
||||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
[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)
|
## 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
|
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||||
|
|||||||
@@ -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`
|
||||||
+14
-3
@@ -3,8 +3,9 @@
|
|||||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||||
reached at more than one address: a LAN IP at home, another at the office, its
|
reached at more than one address: a LAN IP at home, another at the office, its
|
||||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||||
app is doing to reach the one in use, step by step, as it happens.
|
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||||
|
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||||
|
|
||||||
The code is in three modules, all stdlib-only Python on your computer:
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ and ranks them:
|
|||||||
5. addresses that only ever worked on other networks;
|
5. addresses that only ever worked on other networks;
|
||||||
6. Tailscale addresses while Tailscale is off.
|
6. Tailscale addresses while Tailscale is off.
|
||||||
|
|
||||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||||
still trying. If SSH to the winner fails in a way another address could fix
|
still trying. If SSH to the winner fails in a way another address could fix
|
||||||
(a different device answered there, or the link dropped), the next one that
|
(a different device answered there, or the link dropped), the next one that
|
||||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
|||||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||||
services and checks `ALIAS.local` and `frame.local`.
|
services and checks `ALIAS.local` and `frame.local`.
|
||||||
|
|
||||||
|
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||||
|
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||||
|
the headset reports a LAN IP on the same network as this computer and that IP
|
||||||
|
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||||
|
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||||
|
name still leads. A new or edited address is tested straight away. While
|
||||||
|
connected, **Reconnect** applies your changes now rather than at the next
|
||||||
|
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||||
|
one that answers promptly wins. It doesn't pick a particular address.
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
A network is told apart by its default gateway: the router's IP address plus its
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
|
|||||||
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# UEVR from Frame Control, 2026-09-29
|
||||||
|
|
||||||
|
**Verified observations**, with limits below. Same device and software as
|
||||||
|
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||||
|
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||||
|
Frame's AEST clock. Headset tests ran under the shared
|
||||||
|
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||||
|
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||||
|
93 %, on charge throughout.
|
||||||
|
|
||||||
|
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||||
|
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||||
|
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||||
|
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||||
|
to SteamVR, not that the image is correct.
|
||||||
|
|
||||||
|
## Manual injection (11:13–11:28)
|
||||||
|
|
||||||
|
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||||
|
started with the game process's Wine variables, and its WPF window drew
|
||||||
|
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||||
|
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||||
|
|
||||||
|
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||||
|
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||||
|
was not restored on the next start.
|
||||||
|
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||||
|
`0x2000001` (the game) at the injector's coordinates, and
|
||||||
|
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||||
|
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||||
|
Inject, `/proc/<game>/maps` contained
|
||||||
|
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||||
|
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[11:17:32.948] Hooking D3D11
|
||||||
|
[11:17:33.020] Hooked DirectX 11
|
||||||
|
[11:17:34.263] Framework initialized
|
||||||
|
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||||
|
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||||
|
[11:17:39.792] is stereo enabled called!
|
||||||
|
```
|
||||||
|
|
||||||
|
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||||
|
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||||
|
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||||
|
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||||
|
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||||
|
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||||
|
|
||||||
|
A second session's injector exited within about 20 seconds. The third,
|
||||||
|
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||||
|
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||||
|
files were removed.
|
||||||
|
|
||||||
|
## Through Frame Control (11:33–11:49)
|
||||||
|
|
||||||
|
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||||
|
page's **VR mod** dialog, driven with a headless Chromium:
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
|---|---|
|
||||||
|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||||
|
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||||
|
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||||
|
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||||
|
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||||
|
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||||
|
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||||
|
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||||
|
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||||
|
|
||||||
|
## The GUI injector wasn't reliable (12:02–12:40)
|
||||||
|
|
||||||
|
After review fixes, cold starts through the API with UEVR's own injector:
|
||||||
|
|
||||||
|
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||||
|
relaunched the injector didn't rescue a bad session. In both failures of
|
||||||
|
the last batch, the first injector ignored the clicks and the next two
|
||||||
|
never showed a window within 60 s.
|
||||||
|
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||||
|
with OpenVR still unselected. The injector's log had no error.
|
||||||
|
|
||||||
|
Lock released at 12:40 before redesigning.
|
||||||
|
|
||||||
|
## Frame Control's own injector (12:44–12:52)
|
||||||
|
|
||||||
|
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||||
|
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||||
|
the game's Wine session. The old install was removed and the new one
|
||||||
|
installed without the headset lock, since neither launches anything. The
|
||||||
|
prefix listing after removal matched the pre-install state; the new install
|
||||||
|
is 47 MB.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
|---|---|
|
||||||
|
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||||
|
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||||
|
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||||
|
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||||
|
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||||
|
|
||||||
|
Battery 98–100 % on charge. Lock released at 12:52.
|
||||||
|
|
||||||
|
## After the second review (13:01–13:03)
|
||||||
|
|
||||||
|
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||||
|
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||||
|
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||||
|
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||||
|
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||||
|
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||||
|
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||||
|
through the API, plus the UI run.
|
||||||
|
|
||||||
|
## Left on the Frame
|
||||||
|
|
||||||
|
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||||
|
installed; nothing else from this work is left.
|
||||||
|
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||||
|
SteamVR were running at the end.
|
||||||
|
- The test lock was released at 13:03.
|
||||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
|||||||
|
|
||||||
## Casting
|
## Casting
|
||||||
|
|
||||||
**Cast headset view** starts the existing headset Live view and requests full
|
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
||||||
screen where supported. Show that screen to people in the room, or use the
|
screen where supported. Show that screen to people in the room, or use the
|
||||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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
|
## Clipboard
|
||||||
|
|
||||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
`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
|
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
|
||||||
headset.
|
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.
|
||||||
+44
-15
@@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
The window has six tabs: **Home** (what the headset's doing and what you'd do
|
||||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||||
connection pill in the header always shows which headset, which network this
|
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||||
computer is on, the address in use or being tried, and each step of connecting
|
searchable Control Center, metrics, supported settings and headset guidance).
|
||||||
as it happens; click it for the whole timeline. When the Frame can't be
|
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
|
||||||
|
connection pill in the header always shows which headset and whether it's
|
||||||
|
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||||
|
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||||
reached, a banner says why in plain words, what was tried, and counts down to
|
reached, a banner says why in plain words, what was tried, and counts down to
|
||||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||||
counts them while they run.
|
counts them while they run.
|
||||||
@@ -43,11 +49,22 @@ counts them while they run.
|
|||||||
gamescope's flat layer instead.
|
gamescope's flat layer instead.
|
||||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||||
save them to `~/Pictures/SteamFrame`.
|
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.
|
||||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
- **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.
|
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||||
the desktop, Lepton and xrdp are running.
|
the desktop, Lepton and xrdp are running.
|
||||||
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
|
- **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,
|
- **Get games**: every game you own with its Steam Frame rating (Verified,
|
||||||
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
|
||||||
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
with live progress. Search the Steam store with prices and Frame ratings; Buy
|
||||||
@@ -74,8 +91,14 @@ counts them while they run.
|
|||||||
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
|
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
|
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**
|
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
|
with Launch and Remove; see [sideloading.md](sideloading.md).
|
||||||
Frame clipboard.
|
- **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
|
- **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
|
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
|
scroll with the laser, and type on the Mac. Streams hardware H.264 through
|
||||||
@@ -121,6 +144,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,
|
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||||
not a guessed universal temperature threshold.
|
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`
|
**Audio** controls the default output sink through the existing `/api/volume`
|
||||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||||
@@ -133,7 +162,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,
|
Other settings link to the existing forms and their validation, progress,
|
||||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
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
|
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||||
its action shown only when the server is on macOS.
|
its action shown only when the server is on macOS.
|
||||||
|
|
||||||
@@ -214,8 +243,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
|
|||||||
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
|
||||||
and others). The log is at
|
and others). The log is at
|
||||||
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
|
`~/.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
|
instead of the app, sending the clipboard, or copying the headset's to this
|
||||||
`xclip` (X11).
|
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -239,5 +268,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
|||||||
require a separate user approval; power also retains its password prompt. The
|
require a separate user approval; power also retains its password prompt. The
|
||||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||||
covers putting it in a Chromium panel on the Frame.
|
covers putting it in a Chromium panel on the Frame.
|
||||||
@@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||||
|
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+5
-4
@@ -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
|
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
|
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).
|
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
|
Sending text to the Frame's clipboard, or copying the Frame's to the phone
|
||||||
headset, as on the desktop app.
|
(**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
|
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.
|
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
|
||||||
@@ -114,8 +115,8 @@ don't.
|
|||||||
|
|
||||||
## Family and comfort
|
## Family and comfort
|
||||||
|
|
||||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
||||||
permission and sends a local test. These are local notifications, not APNs push;
|
permission and sends a local test. These are local notifications, not APNs push;
|
||||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||||
runs. See [the behavior and verification limits](family-comfort.md).
|
runs. See [the behavior and verification limits](family-comfort.md).
|
||||||
+85
-15
@@ -1,11 +1,14 @@
|
|||||||
# Flat-to-VR mods and Beat Saber songs
|
# Flat-to-VR mods and Beat Saber songs
|
||||||
|
|
||||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||||
has not been inspected. There is no Mods button until the underlying
|
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||||
install, playback and removal have been checked.
|
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||||
|
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||||
|
([below](#beat-saber-songs-first)).
|
||||||
|
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||||
|
|
||||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||||
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
|
|||||||
|
|
||||||
## Per-game support
|
## Per-game support
|
||||||
|
|
||||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||||
the observation stated, not a promise that the game is playable. “Documented”
|
the observation stated, not a promise that the game is playable. “Documented”
|
||||||
means an upstream source describes it; “inferred” means it still needs a test.
|
means an upstream source describes it; “inferred” means it still needs a test.
|
||||||
|
|
||||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||||
|
|
||||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||||
visible output and failures. It also records a SteamVR restart during the
|
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
visible output, injection and failures.
|
||||||
|
|
||||||
|
## UEVR from Frame Control
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||||
|
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||||
|
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||||
|
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||||
|
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||||
|
like `frame_steam.py`.
|
||||||
|
|
||||||
|
- **Which games.** Unreal Engine games, recognised by their
|
||||||
|
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||||
|
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||||
|
prefix, so it must have been played once.
|
||||||
|
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||||
|
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||||
|
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||||
|
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||||
|
entries that escape it, symlinks and oversized contents, then moved into
|
||||||
|
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||||
|
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||||
|
what to remove, including UEVR's own settings folders only if they didn't
|
||||||
|
exist before. Install and remove refuse while the game is running, and only
|
||||||
|
one mod action runs at a time.
|
||||||
|
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||||
|
waits for its window. It then runs a short script under that Python, with
|
||||||
|
the game's Proton, prefix and Wine settings read from the game process, so
|
||||||
|
both share one Wine session. The script repeats what UEVR's own injector does
|
||||||
|
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||||
|
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||||
|
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||||
|
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||||
|
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||||
|
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||||
|
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||||
|
no game uses it. Verified: the prefix's file listing matched its
|
||||||
|
pre-install state afterwards.
|
||||||
|
|
||||||
|
Why not UEVR's own injector (verified 2026-09-29):
|
||||||
|
|
||||||
|
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||||
|
of downloads) in the prefix.
|
||||||
|
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||||
|
clicks, raising or refocusing the injector window don't change that, so in
|
||||||
|
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||||
|
events straight to its window does work.
|
||||||
|
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||||
|
saved OpenVR choice.
|
||||||
|
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||||
|
new injector ignored input or never showed its window, even after
|
||||||
|
relaunching. Its process list reads each process's main window title, a
|
||||||
|
cross-process window message; a window that isn't answering would block its
|
||||||
|
UI thread (inferred from the frontend's source, not proven). Without a
|
||||||
|
`HOME` it also died in .NET at startup.
|
||||||
|
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||||
|
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||||
|
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||||
|
Neither stopped the injection.
|
||||||
|
|
||||||
## Beat Saber: songs first
|
## Beat Saber: songs first
|
||||||
|
|
||||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
|||||||
|
|
||||||
## Requirements for our manager
|
## Requirements for our manager
|
||||||
|
|
||||||
These are **planned**, not implemented or verified:
|
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||||
|
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||||
|
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||||
|
same, and none of it is built for them:
|
||||||
|
|
||||||
1. Resolve the selected Steam game's real library, installed build, executable
|
1. Resolve the selected Steam game's real library, installed build, executable
|
||||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||||
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
|
|||||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||||
including the SHA-512 hashes used for the test runtimes.
|
including the SHA-512 hashes used for the injector tests.
|
||||||
|
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||||
|
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||||
|
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||||
|
embeddable Python's `sha256_sum`.
|
||||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||||
including per-game installation and the need to preserve original DLLs.
|
including per-game installation and the need to preserve original DLLs.
|
||||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ check in the headset:
|
|||||||
- Keys from SteamVR's on-screen keyboard.
|
- Keys from SteamVR's on-screen keyboard.
|
||||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
- 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
|
## Unconfirmed claims made in these docs
|
||||||
|
|
||||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
|||||||
separate from Frame Control. Public reports describe some Proton support;
|
separate from Frame Control. Public reports describe some Proton support;
|
||||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||||
and sources are in [VR utilities](vr-utilities.md).
|
and sources are in [VR utilities](vr-utilities.md).
|
||||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||||
headset creates its own gamescope panel using built-in tools. It needs no
|
headset creates its own gamescope panel using built-in tools. It needs no
|
||||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -132,8 +132,8 @@ runs.
|
|||||||
|
|
||||||
## Report a problem
|
## Report a problem
|
||||||
|
|
||||||
**Report a problem** is the warning-sign button in the header, also in the
|
**Report a problem** is the speech-bubble button in the header, also in the
|
||||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
||||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||||
same way as the analytics above, so only the maintainer can read it and
|
same way as the analytics above, so only the maintainer can read it and
|
||||||
nothing is published. It works whatever the analytics settings are, because
|
nothing is published. It works whatever the analytics settings are, because
|
||||||
@@ -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.
|
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||||
Those requests carry no id. To stop it, set
|
Those requests carry no id. To stop it, set
|
||||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
`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.
|
||||||
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
|||||||
| Script | Runs on | Purpose |
|
| 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/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/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/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**) |
|
| `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
|
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,
|
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.
|
behaviour for a zip that isn't a game.
|
||||||
|
|
||||||
From a terminal:
|
From a terminal:
|
||||||
|
|||||||
+6
-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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
|
|||||||
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
- `search()` raises a user-readable `SourceError` with the browse URL (zero
|
||||||
limit returns no rows). It does not invent app results or report a false
|
limit returns no rows). It does not invent app results or report a false
|
||||||
“no matching games”. The aggregate search UI should surface this source error.
|
“no matching games”. The aggregate search UI should surface this source error.
|
||||||
|
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
|
||||||
|
with a link to its website instead of an on/off switch.
|
||||||
- `details()` accepts a numeric listing id and returns its canonical page link,
|
- `details()` accepts a numeric listing id and returns its canonical page link,
|
||||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||||
@@ -83,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
|
|||||||
|
|
||||||
## OBB files
|
## 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
|
```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
|
||||||
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.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
|
||||||
|
|||||||
+59
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
|||||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||||
This page covers how it works underneath, so you can do the same from a shell.
|
This page covers how it works underneath, so you can do the same from a shell.
|
||||||
|
|
||||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||||
Frame rating; there is no mod installer yet.
|
per-game table. Mod support is separate from Steam's Frame rating.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -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
|
returns nothing without `cc`, so Frame Control takes the country from
|
||||||
`SteamClient.User.GetIPCountry()` on the Frame.
|
`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
|
## Not yet checked
|
||||||
|
|
||||||
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
- Free-to-play games: `steam://install` should stop at state 3 (free license)
|
||||||
|
|||||||
+15
-1
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
|||||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||||
|
|
||||||
|
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
|
||||||
|
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
|
||||||
|
Remote Desktop never asks for a user or password. It warns that the certificate
|
||||||
|
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
|
||||||
|
frame" box with the username blank. Any user but `steamos` gets "User does not
|
||||||
|
exist, or could not be authenticated". Signing in as `steamos` with the
|
||||||
|
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||||
|
It's a new session on display `:10`, separate from what the headset shows,
|
||||||
|
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
|
||||||
|
it running, and the next sign-in reconnects to it. To end it, find its
|
||||||
|
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
|
||||||
|
and run `loginctl terminate-session <id>`; the headset's own session keeps
|
||||||
|
running.
|
||||||
|
|
||||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||||
Mac with keyboard, mouse, and clipboard.
|
Mac with keyboard, mouse, and clipboard.
|
||||||
@@ -130,7 +144,7 @@ capture it.
|
|||||||
|
|
||||||
## Input: type and point in the Frame from the Mac or iPhone
|
## 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
|
(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,
|
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
|
two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||||
|
|||||||
+94
-2
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
|||||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||||
|
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
|
||||||
|
|
||||||
## Unit tests
|
## Unit tests
|
||||||
|
|
||||||
@@ -75,8 +76,8 @@ its guards want.
|
|||||||
| `sshd` with key and password logins, `rsync`, `python3` | Real |
|
| `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 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 |
|
| 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 |
|
| **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 | The JS engine is real; the objects are 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 |
|
| `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 |
|
| 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 |
|
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
|
||||||
@@ -186,6 +187,97 @@ 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
|
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||||
Steam accepts.
|
Steam accepts.
|
||||||
|
|
||||||
|
## Windows test VM
|
||||||
|
|
||||||
|
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||||
|
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||||
|
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||||
|
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||||
|
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||||
|
|
||||||
|
Setting it up, once, on the Linux machine:
|
||||||
|
|
||||||
|
- **Windows comes from Microsoft.** The container downloads the Windows 11
|
||||||
|
image from Microsoft on first start. Windows runs unactivated, which is fine
|
||||||
|
for testing. Don't use activation workarounds or third-party Windows images.
|
||||||
|
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
|
||||||
|
`127.0.0.1:8006:8006` (the web console) and, if you need it,
|
||||||
|
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
|
||||||
|
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
|
||||||
|
testing, and a `docker stop` shuts Windows down cleanly.
|
||||||
|
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
|
||||||
|
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
|
||||||
|
as its default shell, and put a dedicated public key (for example
|
||||||
|
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||||
|
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||||
|
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||||
|
and the tests.
|
||||||
|
|
||||||
|
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
|
||||||
|
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
|
||||||
|
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
|
||||||
|
scripts/windows-vm.sh shot screen.png # what's on its screen
|
||||||
|
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
|
||||||
|
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
|
||||||
|
scripts/windows-vm.sh down # shut Windows down
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Clicks go through a scheduled task.** Commands over SSH run in a
|
||||||
|
session with no desktop, so `click` and `scroll` write the position to a
|
||||||
|
file, and a scheduled task running as the signed-in user replays it. The
|
||||||
|
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
|
||||||
|
the same time run one after another. QEMU's own `mouse_move` is relative
|
||||||
|
and drifts, so the script doesn't use it.
|
||||||
|
- **Screenshots may not show the pointer.** Check the result of a click (a
|
||||||
|
menu that opens, a button that changes) rather than the pointer's position.
|
||||||
|
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
|
||||||
|
Do the same if you run `ssh` in the VM by hand.
|
||||||
|
- **Starting the app.** Run Frame Control in the signed-in session, not over
|
||||||
|
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
|
||||||
|
one `click` uses.
|
||||||
|
|
||||||
|
**Testing against a real Frame.** The VM reaches the headset on the LAN like
|
||||||
|
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
|
||||||
|
that makes the VM's keys. So the VM doesn't keep access between test runs,
|
||||||
|
afterwards take the lines it added out of the headset's
|
||||||
|
`~/.ssh/authorized_keys`: the ones matching the VM's
|
||||||
|
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
|
||||||
|
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
|
||||||
|
in the VM now fails. Then, around each run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
|
||||||
|
# ... test ...
|
||||||
|
scripts/windows-vm.sh frame-key remove # and take it out again
|
||||||
|
```
|
||||||
|
|
||||||
|
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
|
||||||
|
already trusts that key through another line. `remove` deletes only the exact
|
||||||
|
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
|
||||||
|
the file alone if it can't rewrite it. Follow the shared-device
|
||||||
|
procedure in [Headset smoke test](#headset-smoke-test) before installing or
|
||||||
|
launching anything on the headset.
|
||||||
|
|
||||||
|
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
|
||||||
|
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
|
||||||
|
connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||||
|
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||||
|
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||||
|
|
||||||
|
## 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
|
## Owned media player
|
||||||
|
|
||||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
|||||||
an installed app with a blank tile.
|
an installed app with a blank tile.
|
||||||
|
|
||||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||||
Set the optional free key in Frame Control's **Library artwork settings**, or
|
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
||||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||||
settings override the saved key. Without a key there are no provider calls or
|
settings override the saved key. Without a key there are no provider calls or
|
||||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
|||||||
|
|
||||||
## Our performance HUD
|
## Our performance HUD
|
||||||
|
|
||||||
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
||||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
|||||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||||
|
|
||||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||||
it running. This mode also returned live timing in a read-only device probe.
|
it running. This mode also returned live timing in a read-only device probe.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
|||||||
|
|
||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
In **Tools → Media in the headset**, send a file, choose its layout and press
|
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
||||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||||
**Stop** removes both. Refresh reads the library and the player's state.
|
**Stop** removes both. Refresh reads the library and the player's state.
|
||||||
The screen follows your head; it isn't a saved world-space panel.
|
The screen follows your head; it isn't a saved world-space panel.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||||
@@ -35,6 +37,8 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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>"; };
|
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>"; };
|
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>"; };
|
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||||
@@ -100,6 +104,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||||
|
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
|
||||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||||
);
|
);
|
||||||
@@ -111,6 +116,7 @@
|
|||||||
children = (
|
children = (
|
||||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||||
|
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
|
||||||
);
|
);
|
||||||
path = FrameControlTests;
|
path = FrameControlTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -260,6 +266,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||||
|
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
|
||||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||||
@@ -279,6 +286,7 @@
|
|||||||
files = (
|
files = (
|
||||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||||
|
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
|
|||||||
/// Install links that arrived before the page was ready for them.
|
/// Install links that arrived before the page was ready for them.
|
||||||
@Published var pendingInstallLinks: [InstallLink] = []
|
@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 link: FrameLink?
|
||||||
private var server: HeadsetServer?
|
private var server: HeadsetServer?
|
||||||
private var forwarder: PortForwarder?
|
private var forwarder: PortForwarder?
|
||||||
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
|
|||||||
|
|
||||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
FrameSettings.parse(host: host, user: user)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||||
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
|
|||||||
private func invalidate() {
|
private func invalidate() {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
retrying = false
|
retrying = false
|
||||||
|
webRecoveryMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
/// 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 Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
|
|||||||
|
|
||||||
private var browser: NWBrowser?
|
private var browser: NWBrowser?
|
||||||
private var probeTask: Task<Void, Never>?
|
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()
|
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
|
found = nil
|
||||||
since = Date()
|
since = Date()
|
||||||
browse()
|
if search.discovers && discoveryEnabled { browse(generation: mine) }
|
||||||
probeTask = Task { [weak self] in
|
probeTask = Task { [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
guard let self else { return }
|
guard let self, self.search.accepts(mine) else { return }
|
||||||
let host = self.fallback
|
if self.found == nil {
|
||||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
let answers = await self.probe(host)
|
||||||
self.found = Found(host: host, name: 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)
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
}
|
}
|
||||||
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
|
search.stop()
|
||||||
browser?.cancel()
|
browser?.cancel()
|
||||||
browser = nil
|
browser = nil
|
||||||
probeTask?.cancel()
|
probeTask?.cancel()
|
||||||
probeTask = nil
|
probeTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func browse() {
|
private func browse(generation: UUID) {
|
||||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||||
for result in results {
|
for result in results {
|
||||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||||
Self.resolve(result.endpoint) { host in
|
Self.resolve(result.endpoint) { host in
|
||||||
Task { @MainActor 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.
|
// A found device is used over the fallback probe.
|
||||||
self.found = Found(host: host, name: name)
|
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.
|
/// 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 {
|
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||||
var host = address, port: UInt16 = 22
|
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
|
||||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
return await sshAnswers(host: target.host, port: UInt16(target.port))
|
||||||
host = target.host
|
|
||||||
port = UInt16(target.port)
|
|
||||||
}
|
|
||||||
return await sshAnswers(host: host, port: port)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import Foundation
|
|||||||
import NIOCore
|
import NIOCore
|
||||||
import NIOSSH
|
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 {
|
struct FrameFailure: LocalizedError {
|
||||||
let message: String
|
let message: String
|
||||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
/// 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 {
|
func shellQuote(_ s: String) -> String {
|
||||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
|||||||
@@ -10,15 +10,31 @@ struct RootView: View {
|
|||||||
case .setup:
|
case .setup:
|
||||||
SetupView(model: model)
|
SetupView(model: model)
|
||||||
case .connecting(let step):
|
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:
|
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() })
|
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||||
case .ready(let url):
|
case .ready(let url):
|
||||||
WebShell(url: url, model: model).ignoresSafeArea()
|
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)
|
.preferredColorScheme(.dark)
|
||||||
@@ -33,20 +49,37 @@ extension Color {
|
|||||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
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 {
|
struct ConnectingView: View {
|
||||||
let step: String
|
let step: String
|
||||||
let host: String?
|
let host: String?
|
||||||
let cancel: () -> Void
|
let cancel: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
RecoveryLayout {
|
||||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
VStack(spacing: 18) {
|
||||||
ProgressView().controlSize(.large)
|
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
ProgressView().controlSize(.large)
|
||||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||||
Button("Change headset", action: cancel).padding(.top, 8)
|
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
|
let change: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
RecoveryLayout {
|
||||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
VStack(spacing: 16) {
|
||||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||||
if needsPairing {
|
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
if needsPairing {
|
||||||
} else if canRetry {
|
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||||
Button("Try again", action: retry).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 reachable: () -> Void
|
||||||
let change: () -> Void
|
let change: () -> Void
|
||||||
@State private var pulse = false
|
@State private var pulse = false
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
RecoveryLayout {
|
||||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
VStack(spacing: 18) {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||||
.opacity(pulse ? 1 : 0.55)
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
.opacity(reduceMotion || pulse ? 1 : 0.55)
|
||||||
Text("Waiting for your Frame").font(.title3.bold())
|
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
Text("Waiting for your Frame").font(.title3.bold())
|
||||||
.multilineTextAlignment(.center)
|
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
.multilineTextAlignment(.center)
|
||||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
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)
|
.padding(28)
|
||||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
.frame(maxWidth: 480)
|
||||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
.onAppear { pulse = true }
|
||||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
.task(id: host) {
|
||||||
}
|
while !Task.isCancelled {
|
||||||
.padding(28)
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||||
.frame(maxWidth: 480)
|
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
|
||||||
.onAppear { pulse = true }
|
reachable()
|
||||||
.task(id: host) {
|
return
|
||||||
while !Task.isCancelled {
|
}
|
||||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
||||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
|
||||||
reachable()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ struct SetupView: View {
|
|||||||
@State private var showHelp = false
|
@State private var showHelp = false
|
||||||
@FocusState private var passwordFocused: Bool
|
@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 {
|
private var host: String {
|
||||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
|
||||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -34,19 +33,19 @@ struct SetupView: View {
|
|||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.background(Color.frameBackground)
|
.background(Color.frameBackground)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
manualHost = model.settings?.host ?? ""
|
manualHost = model.settings?.address ?? ""
|
||||||
user = model.settings?.user ?? "steamos"
|
user = model.settings?.user ?? "steamos"
|
||||||
var fallback = model.settings?.host
|
var fallback = model.settings?.address
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||||
#endif
|
#endif
|
||||||
finder.start(fallback: fallback)
|
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
|
||||||
}
|
}
|
||||||
.onDisappear { finder.stop() }
|
.onDisappear { finder.stop() }
|
||||||
// After a few seconds of not finding it, say exactly what to check.
|
// After a few seconds of not finding it, say exactly what to check.
|
||||||
.task(id: finder.since) {
|
.task(id: finder.since) {
|
||||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
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)
|
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
.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))
|
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||||
TextField("User", text: $user)
|
TextField("User", text: $user)
|
||||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||||
@@ -149,7 +148,9 @@ struct SetupView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: manualHost) { _, value in
|
.onChange(of: manualHost) { _, value in
|
||||||
// A typed address replaces the search.
|
// 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
|
web.isInspectable = true
|
||||||
#endif
|
#endif
|
||||||
context.coordinator.web = web
|
context.coordinator.web = web
|
||||||
web.load(URLRequest(url: url))
|
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||||
return web
|
return web
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ web: WKWebView, context: Context) {
|
func updateUIView(_ web: WKWebView, context: Context) {
|
||||||
if context.coordinator.loaded != url {
|
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
|
||||||
context.coordinator.loaded = url
|
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||||
web.load(URLRequest(url: url))
|
|
||||||
}
|
}
|
||||||
context.coordinator.deliverInstallLinks()
|
context.coordinator.deliverInstallLinks()
|
||||||
}
|
}
|
||||||
@@ -47,16 +46,22 @@ struct WebShell: UIViewRepresentable {
|
|||||||
(() => {
|
(() => {
|
||||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||||
let installCb = null;
|
let installCb = null;
|
||||||
|
window.__frameDocumentID = crypto.randomUUID();
|
||||||
window.frameApp = {
|
window.frameApp = {
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
|
preferences: {
|
||||||
|
read: () => call("preferencesRead"),
|
||||||
|
write: (key, value) => call("preferencesWrite", { key, value }),
|
||||||
|
},
|
||||||
notify: (message, request) => call("notify", { message, request }),
|
notify: (message, request) => call("notify", { message, request }),
|
||||||
readClipboard: () => call("readClipboard"),
|
readClipboard: () => call("readClipboard"),
|
||||||
|
writeClipboard: (text) => call("writeClipboard", text),
|
||||||
setUpConnection: () => call("setUpConnection"),
|
setUpConnection: () => call("setUpConnection"),
|
||||||
open: (what) => call("open", what),
|
open: (what) => call("open", what),
|
||||||
saveImages: (images) => call("saveImages", images),
|
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
|
let model: AppModel
|
||||||
weak var web: WKWebView?
|
weak var web: WKWebView?
|
||||||
var loaded: URL?
|
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) {
|
init(model: AppModel) {
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -118,8 +134,20 @@ struct WebShell: UIViewRepresentable {
|
|||||||
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
|
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":
|
case "readClipboard":
|
||||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
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":
|
case "setUpConnection":
|
||||||
model.showSetup()
|
model.showSetup()
|
||||||
replyHandler(nil, nil)
|
replyHandler(nil, nil)
|
||||||
@@ -135,9 +163,20 @@ struct WebShell: UIViewRepresentable {
|
|||||||
share(images)
|
share(images)
|
||||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||||
case "installLinkReady":
|
case "installLinkReady":
|
||||||
installReady = true
|
guard let id = arg as? String, let web,
|
||||||
deliverInstallLinks()
|
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
|
||||||
replyHandler(nil, nil)
|
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:
|
default:
|
||||||
replyHandler(nil, "unknown request \(name)")
|
replyHandler(nil, "unknown request \(name)")
|
||||||
}
|
}
|
||||||
@@ -145,13 +184,23 @@ struct WebShell: UIViewRepresentable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func deliverInstallLinks() {
|
func deliverInstallLinks() {
|
||||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||||
let links = model.pendingInstallLinks
|
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
|
||||||
model.pendingInstallLinks = []
|
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||||
for link in links {
|
guard let json = try? JSONSerialization.data(withJSONObject: req),
|
||||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
let text = String(data: json, encoding: .utf8),
|
||||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
|
||||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
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)
|
(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.
|
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||||
|
@MainActor
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
#if DEBUG
|
||||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
// 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
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
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/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.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
|||||||
|
|
||||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||||
|
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
|
||||||
|
# The VM is a dockur/windows container on another machine; this reaches it over
|
||||||
|
# SSH through that machine. See docs/testing.md#windows-test-vm.
|
||||||
|
#
|
||||||
|
# Usage: scripts/windows-vm.sh <command> [args]
|
||||||
|
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
|
||||||
|
# ps '<PowerShell>' run PowerShell as the VM's user
|
||||||
|
# put <file> [<dir>] copy a file in (default: the user's Downloads)
|
||||||
|
# shot <out.png> save the VM's screen
|
||||||
|
# click <x> <y> click screen pixel x,y in the signed-in session
|
||||||
|
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
|
||||||
|
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
|
||||||
|
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
|
||||||
|
# alias) for a test run, then take it out again
|
||||||
|
#
|
||||||
|
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
|
||||||
|
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
|
||||||
|
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
|
||||||
|
set -euo pipefail
|
||||||
|
setopt extendedglob
|
||||||
|
|
||||||
|
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
|
||||||
|
ctr=${WINVM_CONTAINER:-frame-winvm}
|
||||||
|
user=${WINVM_USER:-frame}
|
||||||
|
port=${WINVM_PORT:-2222}
|
||||||
|
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
|
||||||
|
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
|
||||||
|
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
|
||||||
|
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
|
||||||
|
|
||||||
|
die() { print -u2 "windows-vm: $*"; exit 1 }
|
||||||
|
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
|
||||||
|
# These go into commands run by a shell on the other machine, so keep them plain.
|
||||||
|
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
|
||||||
|
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
|
||||||
|
|
||||||
|
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
|
||||||
|
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
|
||||||
|
vm_ps() {
|
||||||
|
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
|
||||||
|
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
|
||||||
|
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# QEMU's monitor inside the container: one command per line on stdin.
|
||||||
|
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
|
||||||
|
|
||||||
|
# Input has to come from the signed-in desktop session, not SSH's session 0, so
|
||||||
|
# a scheduled task running as the user replays one click or wheel turn written
|
||||||
|
# to input.txt, then deletes the file to say it's done. Any error stops it
|
||||||
|
# before that, so the caller times out instead of reporting a click.
|
||||||
|
INPUT_PS1='$ErrorActionPreference = "Stop"
|
||||||
|
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
|
||||||
|
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||||
|
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
|
||||||
|
"@
|
||||||
|
[WinVm.Input]::SetProcessDPIAware() | Out-Null
|
||||||
|
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
|
||||||
|
Start-Sleep -Milliseconds 150
|
||||||
|
if ($a[2] -eq "click") {
|
||||||
|
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
|
||||||
|
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
|
||||||
|
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
|
||||||
|
Remove-Item "$PSScriptRoot\input.txt"'
|
||||||
|
|
||||||
|
pointer() { # x y click|wheel [notches]
|
||||||
|
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
|
||||||
|
vm_ps '$ErrorActionPreference = "Stop"
|
||||||
|
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
|
||||||
|
# Giving up: end the helper first, or it could wake up and act in a later request.
|
||||||
|
function Abandon {
|
||||||
|
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
|
||||||
|
foreach ($i in 1..25) {
|
||||||
|
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
Remove-Item $req -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
|
||||||
|
# One request at a time: the helper, the task and input.txt are shared. Windows
|
||||||
|
# frees the mutex when this process ends, however it ends.
|
||||||
|
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
|
||||||
|
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
|
||||||
|
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
|
||||||
|
New-Item -ItemType Directory -Force $d | Out-Null
|
||||||
|
[IO.File]::WriteAllText($req, "'"$*"'")
|
||||||
|
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
|
||||||
|
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
|
||||||
|
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
|
||||||
|
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
|
||||||
|
Start-ScheduledTask -TaskName WindowsVmInput
|
||||||
|
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
|
||||||
|
Abandon
|
||||||
|
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
(( $# )) || die "usage: see the top of $0"
|
||||||
|
cmd=$1; shift
|
||||||
|
case $cmd in
|
||||||
|
up)
|
||||||
|
ssh $host "docker start $ctr" >/dev/null
|
||||||
|
for i in {1..60}; do
|
||||||
|
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
die "Windows didn't answer on SSH within 5 minutes" ;;
|
||||||
|
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
|
||||||
|
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
|
||||||
|
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
|
||||||
|
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
|
||||||
|
put)
|
||||||
|
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
|
||||||
|
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
|
||||||
|
shot)
|
||||||
|
(( $# == 1 )) || die "usage: shot <out.png>"
|
||||||
|
print "screendump /tmp/windows-vm-shot.ppm" | monitor
|
||||||
|
sleep 1
|
||||||
|
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
|
||||||
|
import re, struct, sys, zlib
|
||||||
|
d = sys.stdin.buffer.read()
|
||||||
|
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
|
||||||
|
w, h = int(m[1]), int(m[2]); px = d[m.end():]
|
||||||
|
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||||
|
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
|
||||||
|
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||||
|
' $1
|
||||||
|
print $1 ;;
|
||||||
|
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
|
||||||
|
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
|
||||||
|
keys)
|
||||||
|
(( $# )) || die "usage: keys <key>..."
|
||||||
|
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
|
||||||
|
for k; do print "sendkey $k"; done | monitor ;;
|
||||||
|
frame-key)
|
||||||
|
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
|
||||||
|
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
|
||||||
|
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
|
||||||
|
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
|
||||||
|
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
|
||||||
|
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
|
||||||
|
f=~/.ssh/authorized_keys
|
||||||
|
if [ "$1" = add ]; then
|
||||||
|
if grep -qxF "$3" "$f"; then exit 0; fi
|
||||||
|
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
|
||||||
|
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
|
||||||
|
echo "$3" >> "$f"
|
||||||
|
else
|
||||||
|
t=$(mktemp "$f.XXXXXX") || exit 1
|
||||||
|
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
|
||||||
|
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
|
||||||
|
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
print "frame-key $1: done" ;;
|
||||||
|
*) die "unknown command: $cmd (see the top of $0)" ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,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
-32
@@ -14,7 +14,7 @@ function fixture() {
|
|||||||
const elements = new Map();
|
const elements = new Map();
|
||||||
function element(id) {
|
function element(id) {
|
||||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
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);
|
return elements.get(id);
|
||||||
}
|
}
|
||||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
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 f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
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;},
|
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||||
api:(path,body,device)=>{
|
api:(path,body,device)=>{
|
||||||
if(path==='/api/host')return host.promise;
|
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;
|
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||||
};
|
};
|
||||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
padReset(){},ctrlReset(){},resetShots(){},resetClips(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||||
vm.runInContext(`const UI_KEY = 'test-only';
|
vm.runInContext(`const UI_KEY = 'test-only';
|
||||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
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 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 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);
|
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||||
@@ -438,34 +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.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||||
});
|
});
|
||||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
test('every Control Center workflow reaches its intentional workspace', () => {
|
||||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
const {workspaceFixture}=require('./workspace_fixture.cjs');
|
||||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
const f=workspaceFixture();
|
||||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
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'};
|
||||||
let activePage,scrolled;
|
|
||||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
|
||||||
const sections=new Map();
|
|
||||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
|
||||||
window:{scrollTo(){}},$:id=>{
|
|
||||||
if(!ids.has(id))return null;
|
|
||||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
|
||||||
return sections.get(id);
|
|
||||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
|
||||||
const androidStart=html.indexOf('function showAndroidTab(');
|
|
||||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
|
||||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
|
||||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
|
||||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
|
||||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
|
||||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||||
if(!href.startsWith('#')||href==='#connection')continue;
|
if(!href.startsWith('#')||href==='#connection')continue;
|
||||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
|
||||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
|
||||||
assert.equal(activePage,expected[id],href);
|
|
||||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
|
||||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
|
||||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
|
||||||
}
|
}
|
||||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
assert.equal(f.run('PAGES.length'),4);
|
||||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
});
|
||||||
|
// 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.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
|
||||||
self.assertEqual(state()['clipboard'], [text])
|
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):
|
def test_flatpak_install_and_remove(self):
|
||||||
# Installs run as background jobs (server.start_job); uninstall answers at once.
|
# Installs run as background jobs (server.start_job); uninstall answers at once.
|
||||||
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
|
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
|
||||||
|
|||||||
@@ -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
|
#!/usr/bin/env python3
|
||||||
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
|
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
|
||||||
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
|
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 os
|
||||||
import sys
|
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')
|
sys.exit('Could not connect to D-Bus server')
|
||||||
with fs.update() as s:
|
with fs.update() as s:
|
||||||
s['clipboard'].append(args[3])
|
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:
|
else:
|
||||||
sys.exit(f'qdbus6 stub: unsupported {args}')
|
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;
|
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: {
|
appStore: {
|
||||||
get allApps() { return allApps(); },
|
get allApps() { return allApps(); },
|
||||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||||
@@ -109,7 +115,8 @@ function build(steam) {
|
|||||||
if (s?.artwork) delete s.artwork[type];
|
if (s?.artwork) delete s.artwork[type];
|
||||||
},
|
},
|
||||||
// Container fallback in frame_android.stop performs the simulated stop.
|
// 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) {
|
RemoveShortcut(id) {
|
||||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||||
delete steam.compat_tools[String(id)];
|
delete steam.compat_tools[String(id)];
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
|
|||||||
DEVTOOLS_PORT = 8080
|
DEVTOOLS_PORT = 8080
|
||||||
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
|
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
|
||||||
GAME_LOGS = '/var/log/fakeframe'
|
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"
|
# 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
|
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
|
||||||
@@ -105,11 +111,24 @@ class JS:
|
|||||||
self._start()
|
self._start()
|
||||||
self.next += 1
|
self.next += 1
|
||||||
with fs.update() as state:
|
with fs.update() as state:
|
||||||
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
|
# What Steam counts as running: launches that started and haven't exited.
|
||||||
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
|
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()
|
self.proc.stdin.flush()
|
||||||
reply = json.loads(self.proc.stdout.readline())
|
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']
|
return reply['result']
|
||||||
|
|
||||||
|
|
||||||
@@ -253,8 +272,10 @@ def add_launch(record):
|
|||||||
os.makedirs(GAME_LOGS, exist_ok=True)
|
os.makedirs(GAME_LOGS, exist_ok=True)
|
||||||
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
|
argv = [REAPER, '-c', REAPER_SH, 'reaper', 'SteamLaunch', f"AppId={record['appid']}", '--', path]
|
||||||
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
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
|
record['pid'] = proc.pid
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
record.update(pid=None, exec_error=str(e))
|
record.update(pid=None, exec_error=str(e))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360
|
|||||||
const sent = [];
|
const sent = [];
|
||||||
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
||||||
const env = {
|
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" }),
|
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 }; }),
|
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
|
||||||
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
|||||||
function page(api) {
|
function page(api) {
|
||||||
const pad = { state: "ready", queue: [], sending: false };
|
const pad = { state: "ready", queue: [], sending: false };
|
||||||
const shown = [];
|
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 };
|
return { pad, shown, ...fns };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// The store's "Add a source" login handling and GitHub token field, run in node against
|
||||||
|
// the real functions from ui/index.html. A login typed into the address must leave the
|
||||||
|
// address field (into the login fields) before anything is sent, and stay out of it.
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||||
|
const lines = src.split("\n");
|
||||||
|
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||||
|
|
||||||
|
function grab(name) {
|
||||||
|
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
|
||||||
|
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||||
|
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||||
|
}
|
||||||
|
fail(`unbalanced ${name}`);
|
||||||
|
}
|
||||||
|
const { moveLogin, sourceToken } = new Function(
|
||||||
|
"const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" +
|
||||||
|
grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")();
|
||||||
|
|
||||||
|
function form(url, username = "", password = "") {
|
||||||
|
const details = { open: false };
|
||||||
|
const field = value => ({ value, closest: () => details });
|
||||||
|
return { details, elements: { url: field(url), username: field(username), password: field(password) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo");
|
||||||
|
moveLogin(f);
|
||||||
|
if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value);
|
||||||
|
if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields");
|
||||||
|
if (!f.details.open) fail("login fields not shown");
|
||||||
|
|
||||||
|
f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw");
|
||||||
|
moveLogin(f);
|
||||||
|
if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw")
|
||||||
|
fail("typed login replaced, or address kept the login");
|
||||||
|
|
||||||
|
// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed.
|
||||||
|
for (const [url, address, user, pass] of [
|
||||||
|
["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"],
|
||||||
|
["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||||
|
["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||||
|
["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||||
|
["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
|
||||||
|
["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"],
|
||||||
|
["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"],
|
||||||
|
["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"],
|
||||||
|
["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"],
|
||||||
|
["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""],
|
||||||
|
]) {
|
||||||
|
f = form(url);
|
||||||
|
moveLogin(f);
|
||||||
|
if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`);
|
||||||
|
if (f.elements.username.value !== user || f.elements.password.value !== pass)
|
||||||
|
fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address the parser rejects that may hold a login is refused and left as typed, never sent.
|
||||||
|
for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) {
|
||||||
|
f = form(url);
|
||||||
|
if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url);
|
||||||
|
if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) {
|
||||||
|
f = form(url);
|
||||||
|
moveLogin(f);
|
||||||
|
if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } });
|
||||||
|
if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html);
|
||||||
|
if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one");
|
||||||
|
const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now();
|
||||||
|
f = form(slow); moveLogin(f);
|
||||||
|
if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong");
|
||||||
|
console.log("ok");
|
||||||
@@ -19,13 +19,13 @@ function grab(name) {
|
|||||||
fail(`unbalanced ${name}`);
|
fail(`unbalanced ${name}`);
|
||||||
}
|
}
|
||||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||||
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||||
"updateInstall", "openStoreApp", "loadOfferDetails"];
|
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||||
const code = names.map(grab).join("");
|
const code = names.map(grab).join("");
|
||||||
|
|
||||||
// A fake page: elements by id. The install panel's elements exist only while
|
// A fake page: elements by id. The install panel's elements exist only while
|
||||||
// #detailBody holds the real app page, as in the browser.
|
// #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 els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
|
||||||
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
|
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
|
||||||
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
|
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
|
||||||
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
|
|||||||
const document = { activeElement: null, querySelector: () => null };
|
const document = { activeElement: null, querySelector: () => null };
|
||||||
const console = { error() {} };
|
const console = { error() {} };
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
|
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); });
|
$, 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"); } }, 0);
|
||||||
return { els, sourceState, calls, ...fns };
|
return { els, sourceState, calls, ...fns };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,14 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
|||||||
if (html.includes('role="alert"')) fail("a good listing showed the error state");
|
if (html.includes('role="alert"')) fail("a good listing showed the error state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A catalogue listing that is already installed offers its management page.
|
||||||
|
{
|
||||||
|
const p = page(undefined, [{ package: offer.package }]);
|
||||||
|
p.sourceState.detail = { app: { name: offer.name, offers: [{ ...offer }] }, choice: 0 };
|
||||||
|
if (p.renderAppDetail() !== true) fail("an installed listing reported a render failure");
|
||||||
|
if (p.els.detailInstall.textContent !== "View installed app") fail("installed listing offered another install");
|
||||||
|
}
|
||||||
|
|
||||||
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
||||||
{
|
{
|
||||||
const p = page();
|
const p = page();
|
||||||
@@ -68,7 +78,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
|||||||
{
|
{
|
||||||
const p = page(async () => { throw new Error("offline"); });
|
const p = page(async () => { throw new Error("offline"); });
|
||||||
p.sourceState.apps = [{ name: "Broken", offers: [] }];
|
p.sourceState.apps = [{ name: "Broken", offers: [] }];
|
||||||
await p.openStoreApp(0);
|
await p.openStoreApp(0, true);
|
||||||
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
|
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
|
||||||
if (p.calls.length) fail("asked for details of a listing that couldn't render");
|
if (p.calls.length) fail("asked for details of a listing that couldn't render");
|
||||||
}
|
}
|
||||||
@@ -90,4 +100,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
|||||||
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
|
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
|
||||||
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
|
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
|
||||||
}
|
}
|
||||||
|
// The featured Install starts a new or failed job, while a job already running or
|
||||||
|
// installed opens its details without another install or a launch command.
|
||||||
|
for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||||
|
[{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
|
||||||
|
const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
|
||||||
|
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
|
||||||
|
if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
|
||||||
|
await p.openStoreApp(0, true);
|
||||||
|
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
|
||||||
|
const installs = p.calls.filter(([path]) => path === "/api/sources/install");
|
||||||
|
if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
|
||||||
|
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||||
|
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||||
|
}
|
||||||
console.log("ok");
|
console.log("ok");
|
||||||
@@ -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):
|
def setUp(self):
|
||||||
with _images._lock:
|
with _images._lock:
|
||||||
_images._urls.clear()
|
_images._urls.clear()
|
||||||
|
_images._tokens.clear()
|
||||||
_images._cache.clear()
|
_images._cache.clear()
|
||||||
|
_images._dns.clear()
|
||||||
|
|
||||||
def test_only_registered_source_images_are_fetchable(self):
|
def test_only_registered_source_images_are_fetchable(self):
|
||||||
with patch.object(_images, 'fetch') as fetch:
|
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)
|
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
|
||||||
|
|
||||||
def test_handles_are_bounded(self):
|
def test_handles_are_bounded(self):
|
||||||
for i in range(4100):
|
with patch.object(_images, 'MAX_TOKENS', 50):
|
||||||
_images.register('https://example.com/%d.png' % i)
|
first = _images.register('https://example.com/0.png')
|
||||||
self.assertEqual(len(_images._urls),4096)
|
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):
|
def test_plain_language_verdict_is_evidence_based(self):
|
||||||
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
|
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
|
|||||||
break
|
break
|
||||||
time.sleep(.01)
|
time.sleep(.01)
|
||||||
self.assertTrue(arrived[1]['event'].wait(2))
|
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):
|
def test_set_enabled_does_not_hold_search_lock_in_source(self):
|
||||||
free = []
|
free = []
|
||||||
@@ -325,7 +325,7 @@ class EndpointTests(SettingsTest):
|
|||||||
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
|
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
|
||||||
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
|
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
|
||||||
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
|
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
|
||||||
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
|
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'):
|
||||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
|
||||||
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
|
||||||
mod.remove_repo.assert_called_once_with(source_id='fdroid')
|
mod.remove_repo.assert_called_once_with(source_id='fdroid')
|
||||||
|
|||||||
@@ -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,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()
|
||||||
@@ -56,6 +56,48 @@ const log = ()=>{}, toast = ()=>{};
|
|||||||
assert.equal($('sessionMinutes').disabled,false);
|
assert.equal($('sessionMinutes').disabled,false);
|
||||||
assert.equal($('sessionCancel').disabled,true);
|
assert.equal($('sessionCancel').disabled,true);
|
||||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
|
'''
|
||||||
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_a_finished_session_leaves_the_defaults(self):
|
||||||
|
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
||||||
|
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||||
|
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
||||||
|
setup = r'''
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const elements = new Map();
|
||||||
|
const $ = id => {
|
||||||
|
// The form's own defaults, as in the page's HTML.
|
||||||
|
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
||||||
|
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
||||||
|
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
||||||
|
return elements.get(id);
|
||||||
|
};
|
||||||
|
const window = {frameApp:{notify:async()=>{}}};
|
||||||
|
const log = ()=>{}, toast = ()=>{};
|
||||||
|
'''
|
||||||
|
checks = r'''
|
||||||
|
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
||||||
|
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
||||||
|
assert.equal($('sessionMinutes').value, '30');
|
||||||
|
assert.equal($('breakMinutes').value, '20');
|
||||||
|
assert.equal($('batteryAlert').checked, true);
|
||||||
|
assert.match($('comfortEvents').textContent, /Session ended/);
|
||||||
|
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
||||||
|
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
||||||
|
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
||||||
|
assert.equal($('heatAlert').checked, false);
|
||||||
|
// The page stays open while that session ends: the next one starts from the defaults again.
|
||||||
|
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
||||||
|
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
||||||
|
assert.equal($('sessionMinutes').value, '30');
|
||||||
|
assert.equal($('breakMinutes').value, '20');
|
||||||
|
assert.equal($('heatAlert').checked, true);
|
||||||
|
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
||||||
|
$('sessionMinutes').value = '50'; // what's typed for the next session
|
||||||
|
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
||||||
|
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
||||||
'''
|
'''
|
||||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ class Coverage(unittest.TestCase):
|
|||||||
self.assertIn('*.js', ui['filter'])
|
self.assertIn('*.js', ui['filter'])
|
||||||
self.assertIn('*.css', 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):
|
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||||
# Run the real aggregator with every external probe stubbed.
|
# Run the real aggregator with every external probe stubbed.
|
||||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||||
|
|||||||
@@ -323,6 +323,29 @@ class Pins(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Registry(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.
|
||||||
|
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||||
|
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||||
|
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||||
|
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||||
|
addrs = self.reg.get(d["id"])["addresses"]
|
||||||
|
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||||
|
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||||
|
self.assertEqual(at_home[0], "192.168.1.40")
|
||||||
|
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||||
|
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||||
|
|
||||||
def test_address_editing(self):
|
def test_address_editing(self):
|
||||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
|
|||||||
state.clear()
|
state.clear()
|
||||||
self.addCleanup(state.clear)
|
self.addCleanup(state.clear)
|
||||||
|
|
||||||
def fetch(self, url, path, maximum):
|
def fetch(self, url, path, maximum, auth=None):
|
||||||
name = url.rsplit('/', 1)[-1]
|
name = url.rsplit('/', 1)[-1]
|
||||||
if self.v1 and name == 'entry.jar':
|
if self.v1 and name == 'entry.jar':
|
||||||
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
|
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
|
||||||
@@ -311,7 +311,7 @@ class Repositories(unittest.TestCase):
|
|||||||
source = self.add()
|
source = self.add()
|
||||||
other = dict(source, id='other-repo')
|
other = dict(source, id='other-repo')
|
||||||
started, release = threading.Event(), threading.Event()
|
started, release = threading.Event(), threading.Event()
|
||||||
def fetch(url, path, maximum):
|
def fetch(url, path, maximum, auth=None):
|
||||||
if threading.current_thread().name == 'slow':
|
if threading.current_thread().name == 'slow':
|
||||||
started.set()
|
started.set()
|
||||||
release.wait(5)
|
release.wait(5)
|
||||||
@@ -355,7 +355,7 @@ class Repositories(unittest.TestCase):
|
|||||||
self.files['entry.jar'], _ = entry_jar(50)
|
self.files['entry.jar'], _ = entry_jar(50)
|
||||||
source = fdroid.add_repo(URL)
|
source = fdroid.add_repo(URL)
|
||||||
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
|
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
|
||||||
def fetch(url, path, maximum):
|
def fetch(url, path, maximum, auth=None):
|
||||||
name = threading.current_thread().name
|
name = threading.current_thread().name
|
||||||
if name in jars and url.endswith('entry.jar'):
|
if name in jars and url.endswith('entry.jar'):
|
||||||
Path(path).write_bytes(jars[name])
|
Path(path).write_bytes(jars[name])
|
||||||
@@ -401,7 +401,7 @@ class Repositories(unittest.TestCase):
|
|||||||
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
|
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
|
||||||
self.v1 = True
|
self.v1 = True
|
||||||
inner = []
|
inner = []
|
||||||
def fetch(url, path, maximum):
|
def fetch(url, path, maximum, auth=None):
|
||||||
if url.endswith('index-v1.jar') and not inner:
|
if url.endswith('index-v1.jar') and not inner:
|
||||||
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
|
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
|
||||||
self.v1 = False
|
self.v1 = False
|
||||||
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
|
|||||||
source = self.add()
|
source = self.add()
|
||||||
self.expire(source)
|
self.expire(source)
|
||||||
release = threading.Event()
|
release = threading.Event()
|
||||||
def slow(url, path, maximum):
|
def slow(url, path, maximum, auth=None):
|
||||||
release.wait(5)
|
release.wait(5)
|
||||||
self.fetch(url, path, maximum)
|
self.fetch(url, path, maximum)
|
||||||
self.fetch_mock.side_effect = slow
|
self.fetch_mock.side_effect = slow
|
||||||
@@ -528,7 +528,7 @@ class Repositories(unittest.TestCase):
|
|||||||
self.expire(source)
|
self.expire(source)
|
||||||
before = self.fetch_mock.call_count
|
before = self.fetch_mock.call_count
|
||||||
release = __import__('threading').Event()
|
release = __import__('threading').Event()
|
||||||
def slow(url, path, maximum):
|
def slow(url, path, maximum, auth=None):
|
||||||
release.wait(5)
|
release.wait(5)
|
||||||
self.fetch(url, path, maximum)
|
self.fetch(url, path, maximum)
|
||||||
self.fetch_mock.side_effect = slow
|
self.fetch_mock.side_effect = slow
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -121,7 +121,30 @@ class LauncherTests(unittest.TestCase):
|
|||||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_idle_resolvers(timeout=10):
|
||||||
|
# Name lookups that outlast their deadline give their slot back from their own thread,
|
||||||
|
# which a busy runner may not schedule before the next test; wait until all are back.
|
||||||
|
from apk_sources import _images
|
||||||
|
held = []
|
||||||
|
try:
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
|
||||||
|
held.append(1)
|
||||||
|
if len(held) == 4:
|
||||||
|
return
|
||||||
|
raise AssertionError('artwork name lookups from an earlier test are still running')
|
||||||
|
finally:
|
||||||
|
for _ in held:
|
||||||
|
_images._resolvers.release()
|
||||||
|
|
||||||
|
|
||||||
class ArtworkTests(unittest.TestCase):
|
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):
|
def test_source_inputs_and_url(self):
|
||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
data = (FIXTURES / 'icon.png').read_bytes()
|
data = (FIXTURES / 'icon.png').read_bytes()
|
||||||
@@ -232,10 +255,7 @@ class ArtworkTests(unittest.TestCase):
|
|||||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||||
finally:
|
finally:
|
||||||
gate.set()
|
gate.set()
|
||||||
deadline = time.monotonic() + 5
|
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
|
||||||
time.sleep(0.01)
|
|
||||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
|
||||||
|
|
||||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
@@ -243,10 +263,14 @@ class ArtworkTests(unittest.TestCase):
|
|||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||||
for _ in range(4): # every slot came back
|
held = 0
|
||||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
try:
|
||||||
for _ in range(4):
|
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||||
_images._resolvers.release()
|
held += 1
|
||||||
|
self.assertEqual(held, 4) # every slot came back
|
||||||
|
finally:
|
||||||
|
for _ in range(held): # even on failure, so later tests don't inherit the leak
|
||||||
|
_images._resolvers.release()
|
||||||
|
|
||||||
def test_deadline_covers_name_resolution(self):
|
def test_deadline_covers_name_resolution(self):
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -897,6 +897,47 @@ class Connecting(unittest.TestCase):
|
|||||||
self.assertEqual(self.routes, [])
|
self.assertEqual(self.routes, [])
|
||||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||||
|
|
||||||
|
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||||
|
d = self.device("nothing.invalid")
|
||||||
|
entered = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
release = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def probe(host, port, update=None):
|
||||||
|
calls.append(host)
|
||||||
|
n = len(calls)
|
||||||
|
entered[n].set()
|
||||||
|
release[n].wait(10)
|
||||||
|
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||||
|
|
||||||
|
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||||
|
with mock.patch.object(fl, "probe", probe):
|
||||||
|
try:
|
||||||
|
tests[0].start()
|
||||||
|
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||||
|
tests[1].start()
|
||||||
|
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||||
|
# The first finishes while the second is still probing: it mustn't show its
|
||||||
|
# rows or mark the second done.
|
||||||
|
release[1].set()
|
||||||
|
tests[0].join(10)
|
||||||
|
self.assertFalse(tests[0].is_alive())
|
||||||
|
running = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertFalse(running["done"])
|
||||||
|
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||||
|
release[2].set()
|
||||||
|
tests[1].join(10)
|
||||||
|
self.assertFalse(tests[1].is_alive())
|
||||||
|
finally:
|
||||||
|
for e in release.values():
|
||||||
|
e.set()
|
||||||
|
for t in tests:
|
||||||
|
if t.ident: # started
|
||||||
|
t.join(10)
|
||||||
|
result = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertTrue(result["done"])
|
||||||
|
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||||
|
|
||||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||||
self.device("localhost")
|
self.device("localhost")
|
||||||
self.hosts({"localhost": "ok"})
|
self.hosts({"localhost": "ok"})
|
||||||
|
|||||||
@@ -56,18 +56,19 @@ class LinkUI(unittest.TestCase):
|
|||||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
self.assertEqual(r.returncode, 0, r.stderr)
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
|
||||||
def test_the_pill_leads_with_the_reason(self):
|
def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
|
||||||
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
|
# The connection redesign keeps the state and retry countdown in the pill,
|
||||||
# the reason comes first, in a few words, and only the countdown gets cut.
|
# with the full failure explanation in its tooltip and details dialog.
|
||||||
self.run_page(r'''
|
self.run_page(r'''
|
||||||
link.s = changed;
|
link.s = changed;
|
||||||
renderPill();
|
renderPill();
|
||||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
|
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||||
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
|
assert.ok($('conn').title.includes(changed.error.message));
|
||||||
// Without a short reason: the stage that failed.
|
// Without a short reason: the stage that failed.
|
||||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||||
renderPill();
|
renderPill();
|
||||||
assert.match($('conn').innerHTML, /<span>Checking the headset's identity failed<\/span>/);
|
assert.match($('conn').innerHTML, /<span>Offline<\/span>/);
|
||||||
|
assert.ok($('conn').title.includes('Something.'));
|
||||||
''')
|
''')
|
||||||
|
|
||||||
def test_the_banner_never_doubles_a_full_stop(self):
|
def test_the_banner_never_doubles_a_full_stop(self):
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_mods # noqa: E402
|
||||||
|
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||||
|
|
||||||
|
APPID = 1067310
|
||||||
|
|
||||||
|
|
||||||
|
def zip_bytes(files, links=()):
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w") as z:
|
||||||
|
for name, data in files.items():
|
||||||
|
z.writestr(name, data)
|
||||||
|
for name in links:
|
||||||
|
info = zipfile.ZipInfo(name)
|
||||||
|
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||||
|
z.writestr(info, "/etc/passwd")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
|
||||||
|
class FakeLibrary(unittest.TestCase):
|
||||||
|
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
home = Path(tmp.name)
|
||||||
|
self.steam = home / ".local/share/Steam"
|
||||||
|
apps = self.steam / "steamapps"
|
||||||
|
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
|
||||||
|
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
|
||||||
|
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
|
||||||
|
self.prefix = apps / f"compatdata/{APPID}/pfx"
|
||||||
|
for sub in ("Roaming", "Local"):
|
||||||
|
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
|
||||||
|
(apps / f"appmanifest_{APPID}.acf").write_text(
|
||||||
|
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
|
||||||
|
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
|
||||||
|
("RECEIPTS", home / ".local/share/frame-control/mods")):
|
||||||
|
p = mock.patch.object(frame_mods, name, value)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.running = None
|
||||||
|
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
|
||||||
|
"openvr_api.dll": b"MZ"})}
|
||||||
|
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
|
||||||
|
self.pin(self.downloads)
|
||||||
|
|
||||||
|
def pin(self, downloads):
|
||||||
|
"""Serve these bytes and pin their real hashes in place of the published ones."""
|
||||||
|
for name in ("UEVR", "PYTHON"):
|
||||||
|
pinned = getattr(frame_mods, name)
|
||||||
|
p = mock.patch.object(frame_mods, name,
|
||||||
|
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
|
||||||
|
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
|
||||||
|
self.urlopen = p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
def managed(self):
|
||||||
|
return self.prefix / "drive_c/frame-control"
|
||||||
|
|
||||||
|
|
||||||
|
class Status(FakeLibrary):
|
||||||
|
def test_unreal_game_is_eligible_and_not_installed(self):
|
||||||
|
st = frame_mods.status(APPID)
|
||||||
|
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
|
||||||
|
(True, "Drop-Win64-Shipping.exe", False, False, True))
|
||||||
|
|
||||||
|
def test_game_without_unreal_binary_is_refused(self):
|
||||||
|
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
|
||||||
|
self.assertFalse(frame_mods.status(APPID)["eligible"])
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.urlopen.assert_not_called()
|
||||||
|
|
||||||
|
def test_uninstalled_game(self):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||||
|
frame_mods.status(620)
|
||||||
|
|
||||||
|
|
||||||
|
class InstallUninstall(FakeLibrary):
|
||||||
|
def test_install_then_uninstall_leaves_prefix_as_found(self):
|
||||||
|
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||||
|
res = frame_mods.install(APPID)
|
||||||
|
self.assertTrue(res["installed"])
|
||||||
|
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
|
||||||
|
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
|
||||||
|
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
|
||||||
|
self.assertFalse(list(self.managed().glob(".staging-*")))
|
||||||
|
# What UEVR writes on its first injection.
|
||||||
|
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||||
|
roaming.mkdir(parents=True)
|
||||||
|
(roaming / "log.txt").write_text("x")
|
||||||
|
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||||
|
self.assertEqual(after, before)
|
||||||
|
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
|
||||||
|
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||||
|
|
||||||
|
def test_existing_uevr_settings_survive_uninstall(self):
|
||||||
|
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||||
|
profile.mkdir(parents=True)
|
||||||
|
(profile / "config.txt").write_text("mine")
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertEqual((profile / "config.txt").read_text(), "mine")
|
||||||
|
|
||||||
|
def test_second_install_is_a_no_op(self):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
calls = self.urlopen.call_count
|
||||||
|
self.assertIn("already installed", frame_mods.install(APPID)["message"])
|
||||||
|
self.assertEqual(self.urlopen.call_count, calls)
|
||||||
|
|
||||||
|
def test_refuses_while_game_runs(self):
|
||||||
|
self.running = 4242
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.running = None
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.running = 4242
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||||
|
|
||||||
|
def test_needs_a_prefix(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.prefix.parent)
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
|
||||||
|
def test_receipt_cannot_point_outside_the_prefix(self):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
r = frame_mods.read_receipt(APPID)
|
||||||
|
victim = self.steam / "steamapps/common/Gravitas"
|
||||||
|
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
|
||||||
|
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertTrue(victim.is_dir())
|
||||||
|
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
r = frame_mods.read_receipt(APPID)
|
||||||
|
users = self.prefix / "drive_c/users"
|
||||||
|
r["remove"].append(str(users))
|
||||||
|
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertTrue(users.is_dir())
|
||||||
|
|
||||||
|
def test_failed_removal_keeps_the_receipt(self):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertFalse(self.managed().exists())
|
||||||
|
|
||||||
|
def test_receipt_cannot_name_another_prefix(self):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
r = frame_mods.read_receipt(APPID)
|
||||||
|
r["prefix"] = "/"
|
||||||
|
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
|
||||||
|
frame_mods.uninstall(APPID)
|
||||||
|
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||||
|
|
||||||
|
|
||||||
|
class Downloads(FakeLibrary):
|
||||||
|
def test_hash_mismatch_deletes_and_installs_nothing(self):
|
||||||
|
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
|
||||||
|
self.assertFalse(self.managed().exists())
|
||||||
|
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||||
|
|
||||||
|
def test_archive_escaping_its_folder_is_refused(self):
|
||||||
|
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
|
||||||
|
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
|
||||||
|
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
|
||||||
|
self.pin(downloads)
|
||||||
|
with self.subTest(), self.assertRaises(frame_mods.Fail):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
|
||||||
|
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_oversized_archive_is_refused(self):
|
||||||
|
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_download_is_stopped(self):
|
||||||
|
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.assertFalse(list(frame_mods.CACHE.glob("*")))
|
||||||
|
|
||||||
|
|
||||||
|
class Actions(FakeLibrary):
|
||||||
|
def test_one_change_at_a_time(self):
|
||||||
|
import fcntl
|
||||||
|
lock = Path(tempfile.mkdtemp()) / "mods.lock"
|
||||||
|
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
|
||||||
|
fcntl.flock(held, fcntl.LOCK_EX)
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
|
||||||
|
frame_mods.main(["install", str(APPID)])
|
||||||
|
self.assertFalse(frame_mods.status(APPID)["installed"])
|
||||||
|
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
|
||||||
|
|
||||||
|
def test_zero_frames_is_an_answer(self):
|
||||||
|
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
|
||||||
|
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
|
||||||
|
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
|
||||||
|
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
|
||||||
|
self.assertIsNone(frame_mods.frame_submits(APPID))
|
||||||
|
|
||||||
|
|
||||||
|
class Start(FakeLibrary):
|
||||||
|
def environ(self, text):
|
||||||
|
"""The game's /proc/<pid>/environ; every other file reads normally."""
|
||||||
|
real = frame_mods.Path.read_text
|
||||||
|
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
|
||||||
|
text if str(path).startswith("/proc/") else real(path, *a, **k))
|
||||||
|
|
||||||
|
def test_needs_install_first(self):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||||
|
frame_mods.start(APPID)
|
||||||
|
|
||||||
|
def run_start(self, reply, loaded=(False, True, True, True)):
|
||||||
|
frame_mods.install(APPID)
|
||||||
|
self.running = 4242
|
||||||
|
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
|
||||||
|
with self.environ(env), \
|
||||||
|
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
|
||||||
|
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
|
||||||
|
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
|
||||||
|
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
|
||||||
|
mock.patch.object(frame_mods.time, "sleep"):
|
||||||
|
return frame_mods.start(APPID), run
|
||||||
|
|
||||||
|
def test_injects_with_the_games_wine_settings(self):
|
||||||
|
res, run = self.run_start('noise\n{"pid": 320}\n')
|
||||||
|
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
|
||||||
|
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
|
||||||
|
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
|
||||||
|
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
|
||||||
|
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
|
||||||
|
self.assertEqual(env["WINEPREFIX"], "/x")
|
||||||
|
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
|
||||||
|
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
|
||||||
|
|
||||||
|
def test_reports_the_injection_error(self):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
|
||||||
|
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
|
||||||
|
|
||||||
|
def test_success_is_checked_in_the_game(self):
|
||||||
|
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
|
||||||
|
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
|
||||||
|
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
|
||||||
|
|
||||||
|
def test_inject_script_is_valid_python(self):
|
||||||
|
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
|
||||||
|
|
||||||
|
|
||||||
|
class Helper(unittest.TestCase):
|
||||||
|
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
|
||||||
|
def test_unexpected_errors_are_still_json(self):
|
||||||
|
# A receipt from another version, without "exe": a KeyError, not a traceback.
|
||||||
|
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
|
||||||
|
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
|
||||||
|
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
|
||||||
|
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
|
||||||
|
self.assertEqual(out.returncode, 1, out.stderr)
|
||||||
|
self.assertIn("KeyError", json.loads(out.stdout)["error"])
|
||||||
|
|
||||||
|
def test_bad_usage_prints_json_error(self):
|
||||||
|
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||||
|
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
self.assertEqual(out.returncode, 1, args)
|
||||||
|
self.assertIn("error", json.loads(out.stdout), args)
|
||||||
|
|
||||||
|
|
||||||
|
class ModRoutes(test_server.ServerGuards):
|
||||||
|
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||||
|
|
||||||
|
def test_mod_input_validation(self):
|
||||||
|
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
|
||||||
|
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
|
||||||
|
status, payload = self.post("/api/mods", body)
|
||||||
|
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||||
|
|
||||||
|
def test_needs_custom_header(self):
|
||||||
|
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
|
||||||
|
|
||||||
|
def test_runs_the_mods_helper_with_its_timeout(self):
|
||||||
|
import server
|
||||||
|
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
|
||||||
|
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
|
||||||
|
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
|
||||||
|
|
||||||
|
|
||||||
|
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||||
|
setattr(ModRoutes, name, None)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""The page's inline scripts share one global scope, so a second top-level function or
|
||||||
|
variable with a name already used replaces the first everywhere, silently."""
|
||||||
|
import html.parser
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
|
||||||
|
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
|
||||||
|
# indented or declared: function, class, let, const, destructuring), while helpers with the
|
||||||
|
# same name inside different functions stay legal. var is the exception (declaring one twice
|
||||||
|
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
|
||||||
|
# real var declarations, not the word in comments, strings or CSS var().
|
||||||
|
DUPLICATES = r'''
|
||||||
|
const vm = require('vm');
|
||||||
|
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||||
|
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
|
||||||
|
catch (e) { console.log(e.message); }
|
||||||
|
'''
|
||||||
|
VARS = r'''
|
||||||
|
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||||
|
let acorn, walk;
|
||||||
|
try {
|
||||||
|
acorn = require('internal/deps/acorn/acorn/dist/acorn');
|
||||||
|
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
|
||||||
|
} catch { console.log('no parser'); process.exit(0); }
|
||||||
|
for (const code of scripts) {
|
||||||
|
let found = null;
|
||||||
|
try {
|
||||||
|
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
|
||||||
|
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
|
||||||
|
});
|
||||||
|
} catch (e) { console.log(e.message); process.exit(0); }
|
||||||
|
if (found) { console.log('declares with var: ' + found); process.exit(0); }
|
||||||
|
}
|
||||||
|
console.log('ok');
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def node(script, scripts, *flags):
|
||||||
|
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
|
||||||
|
return r.stdout.strip() or r.stderr.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def check(scripts):
|
||||||
|
"""'ok', or V8's message for the first name declared twice."""
|
||||||
|
return node(DUPLICATES, scripts)
|
||||||
|
|
||||||
|
|
||||||
|
def check_vars(scripts):
|
||||||
|
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
|
||||||
|
out = node(VARS, scripts, '--expose-internals')
|
||||||
|
if out == 'no parser':
|
||||||
|
raise unittest.SkipTest("this Node doesn't include acorn")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class ClassicScripts(html.parser.HTMLParser):
|
||||||
|
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
|
||||||
|
module has its own scope, and JSON data isn't code). The types a browser runs as script:
|
||||||
|
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
|
||||||
|
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
|
||||||
|
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
|
||||||
|
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
|
||||||
|
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.scripts, self.current = [], None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag == 'script':
|
||||||
|
a = dict(attrs)
|
||||||
|
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
|
||||||
|
self.current = [] if inline else None
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.current is not None:
|
||||||
|
self.current.append(data)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == 'script' and self.current is not None:
|
||||||
|
self.scripts.append(''.join(self.current))
|
||||||
|
if tag == 'script':
|
||||||
|
self.current = None
|
||||||
|
|
||||||
|
|
||||||
|
def classic_scripts(page):
|
||||||
|
p = ClassicScripts()
|
||||||
|
p.feed(page)
|
||||||
|
return p.scripts
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
|
||||||
|
class PageScripts(unittest.TestCase):
|
||||||
|
def test_no_top_level_name_is_declared_twice(self):
|
||||||
|
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||||
|
self.assertGreaterEqual(len(scripts), 2)
|
||||||
|
self.assertEqual(check(scripts), 'ok')
|
||||||
|
|
||||||
|
def test_the_page_declares_nothing_with_var(self):
|
||||||
|
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
|
||||||
|
self.assertEqual(check_vars(scripts), 'ok')
|
||||||
|
|
||||||
|
def test_the_check_finds_what_it_should(self):
|
||||||
|
twice = {
|
||||||
|
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
|
||||||
|
'class': ['class Panel {}', 'class Panel {}'],
|
||||||
|
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
|
||||||
|
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
|
||||||
|
'function and const': ['function f() {}', 'const f = 1;'],
|
||||||
|
}
|
||||||
|
for what, scripts in twice.items():
|
||||||
|
with self.subTest(what):
|
||||||
|
self.assertIn('already been declared', check(scripts))
|
||||||
|
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
|
||||||
|
self.assertEqual(check(helpers), 'ok')
|
||||||
|
|
||||||
|
def test_the_var_check_finds_what_it_should(self):
|
||||||
|
# var: legal to declare twice, so not allowed at all; however it's written.
|
||||||
|
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
|
||||||
|
with self.subTest(code):
|
||||||
|
self.assertIn('declares with var', check_vars([code]))
|
||||||
|
# The word var elsewhere is fine.
|
||||||
|
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
|
||||||
|
self.assertEqual(check_vars(text), 'ok')
|
||||||
|
|
||||||
|
def test_only_inline_classic_scripts_are_checked(self):
|
||||||
|
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
|
||||||
|
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
|
||||||
|
'<script data-src="y" type="text/javascript">let c;</script>'
|
||||||
|
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
|
||||||
|
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""The page calls showPage() while its first script is still running, before names declared
|
||||||
|
later (in that script or the second one) exist. Touching one of them there throws, and the
|
||||||
|
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
|
||||||
|
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||||
|
|
||||||
|
RUN = r'''
|
||||||
|
const vm = require('vm');
|
||||||
|
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
||||||
|
const failures = [];
|
||||||
|
for (const hash of hashes) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
||||||
|
});
|
||||||
|
try { vm.runInContext(code, context); }
|
||||||
|
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(failures));
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
||||||
|
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]
|
||||||
|
# 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()
|
||||||
|
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
||||||
|
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
||||||
|
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 != '$')
|
||||||
|
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[: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)
|
||||||
|
self.assertEqual(json.loads(r.stdout), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertIn(mime, headers['Content-Type'])
|
self.assertIn(mime, headers['Content-Type'])
|
||||||
self.assertIn(marker, payload)
|
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):
|
def test_foreign_host_rejected(self):
|
||||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||||
for path in ("/", "/api/status"):
|
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/stream")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[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("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):
|
def test_captures_are_not_cacheable(self):
|
||||||
# Headset captures show everything on screen; nothing may cache them.
|
# 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]}),
|
||||||
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
||||||
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
|
("/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:
|
for path, body in cases:
|
||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
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):
|
def test_showing_a_shot_needs_it_saved_here(self):
|
||||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
||||||
self.assertEqual(status, 404, payload)
|
self.assertEqual(status, 404, payload)
|
||||||
@@ -524,6 +547,14 @@ class LocalMode(unittest.TestCase):
|
|||||||
self.assertEqual(status, 200, body)
|
self.assertEqual(status, 200, body)
|
||||||
self.assertIsInstance(body["titles"], list)
|
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):
|
def test_open_is_for_the_app_and_power_needs_a_password(self):
|
||||||
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
|
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
|
||||||
status, body = self.request("POST", "/api/open", {"what": "reboot"})
|
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,566 @@
|
|||||||
|
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
|
||||||
|
|
||||||
|
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
|
||||||
|
request processing still run), so these tests see every request and its headers.
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import http.client
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
import urllib.request
|
||||||
|
import urllib.response
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||||
|
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _images, _web, fdroid, github, search
|
||||||
|
import server
|
||||||
|
|
||||||
|
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
|
||||||
|
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
|
||||||
|
REPO = 'https://repo.example/fdroid/repo/'
|
||||||
|
USER, PASSWORD = 'alice', 's3cr@t-pässword'
|
||||||
|
TOKEN = 'ghp_frameControlTestToken0123456789'
|
||||||
|
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
|
||||||
|
REAL_BUILD_OPENER = urllib.request.build_opener
|
||||||
|
|
||||||
|
|
||||||
|
def basic(user, password):
|
||||||
|
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHTTPS(urllib.request.HTTPSHandler):
|
||||||
|
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
|
||||||
|
|
||||||
|
def __init__(self, route):
|
||||||
|
super().__init__()
|
||||||
|
self.route, self.seen = route, []
|
||||||
|
|
||||||
|
def https_open(self, req):
|
||||||
|
auth = req.get_header('Authorization')
|
||||||
|
self.seen.append((req.full_url, auth))
|
||||||
|
status, headers, body = self.route(req.full_url, auth)
|
||||||
|
message = http.client.HTTPMessage()
|
||||||
|
for key, value in headers.items():
|
||||||
|
message[key] = value
|
||||||
|
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
|
||||||
|
response.msg = 'fake'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class Base(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
self.root = Path(tmp.name)
|
||||||
|
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
|
||||||
|
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
|
||||||
|
mock = patch.object(fdroid.frame_host, name, value)
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
self.web = FakeHTTPS(self.route)
|
||||||
|
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
mock = patch.dict(os.environ, {github.ENV: ''})
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
|
||||||
|
state.clear()
|
||||||
|
self.addCleanup(state.clear)
|
||||||
|
self.stderr = io.StringIO()
|
||||||
|
mock = patch('sys.stderr', self.stderr)
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
|
||||||
|
def route(self, url, auth):
|
||||||
|
raise AssertionError('unexpected request ' + url)
|
||||||
|
|
||||||
|
def assertNoSecrets(self, *texts):
|
||||||
|
for text in texts:
|
||||||
|
for secret in SECRETS + (USER + ':',):
|
||||||
|
self.assertNotIn(secret, text)
|
||||||
|
|
||||||
|
def disk(self):
|
||||||
|
"""Everything saved under the data and cache folders except the credentials file."""
|
||||||
|
out = []
|
||||||
|
for path in self.root.rglob('*'):
|
||||||
|
if path.is_file() and path.name != 'source-credentials.json':
|
||||||
|
out.append(path.read_bytes().decode('utf-8', 'replace'))
|
||||||
|
return '\n'.join(out)
|
||||||
|
|
||||||
|
def credentials_file(self):
|
||||||
|
return self.root / 'data' / 'source-credentials.json'
|
||||||
|
|
||||||
|
|
||||||
|
class Repository(Base):
|
||||||
|
"""A repository at REPO that wants USER and PASSWORD."""
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.login = basic(USER, PASSWORD)
|
||||||
|
self.redirect = None # file name -> absolute URL it redirects to
|
||||||
|
self.forbidden = 401
|
||||||
|
|
||||||
|
def serve(self, name):
|
||||||
|
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
|
||||||
|
|
||||||
|
def route(self, url, auth):
|
||||||
|
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
|
||||||
|
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
|
||||||
|
return 200, {}, self.serve(url[len(mirror):])
|
||||||
|
self.assertTrue(url.startswith(REPO), url)
|
||||||
|
if auth != self.login:
|
||||||
|
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
|
||||||
|
name = url[len(REPO):]
|
||||||
|
if self.redirect and name in self.redirect:
|
||||||
|
return 302, {'Location': self.redirect[name]}, b''
|
||||||
|
return 200, {}, self.serve(name)
|
||||||
|
|
||||||
|
def add(self, **kw):
|
||||||
|
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class RepositoryLogin(Repository):
|
||||||
|
def test_add_search_download_with_login_in_the_url(self):
|
||||||
|
source = self.add()
|
||||||
|
self.assertEqual(source['url'], REPO)
|
||||||
|
self.assertEqual(source['fingerprint'], PIN)
|
||||||
|
saved = fdroid.user_repos()[0]
|
||||||
|
self.assertEqual(saved['url'], REPO)
|
||||||
|
self.assertNotIn('login', saved)
|
||||||
|
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
|
||||||
|
downloaded = fdroid.download(source, 'org.example.app', 1)
|
||||||
|
self.assertTrue(downloaded['verified'])
|
||||||
|
self.assertTrue(self.web.seen)
|
||||||
|
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
||||||
|
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
|
||||||
|
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
|
||||||
|
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
|
||||||
|
# The settings file holds no part of the login; the credentials file holds it, privately.
|
||||||
|
settings = (self.root / 'data' / 'apk-repos.json').read_text()
|
||||||
|
self.assertNotIn(USER, settings)
|
||||||
|
self.assertTrue(self.credentials_file().is_file())
|
||||||
|
if os.name != 'nt':
|
||||||
|
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
|
||||||
|
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
|
||||||
|
self.assertEqual(source['url'], REPO)
|
||||||
|
self.web.seen.clear()
|
||||||
|
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
|
||||||
|
self.assertTrue(self.web.seen)
|
||||||
|
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
|
||||||
|
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
|
||||||
|
self.assertTrue(_credentials.has(source['id']))
|
||||||
|
|
||||||
|
def test_wrong_or_missing_login_saves_nothing(self):
|
||||||
|
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
|
||||||
|
({}, 'needs a username and password')]:
|
||||||
|
with self.subTest(kw=kw):
|
||||||
|
with self.assertRaisesRegex(SourceAuthError, message) as caught:
|
||||||
|
fdroid.add_repo(REPO, PIN, **kw)
|
||||||
|
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
|
||||||
|
self.assertNotIn('wrong', str(caught.exception))
|
||||||
|
self.assertEqual(fdroid.user_repos(), [])
|
||||||
|
self.assertFalse(self.credentials_file().exists())
|
||||||
|
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
|
||||||
|
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
|
||||||
|
|
||||||
|
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
|
||||||
|
self.forbidden = 403
|
||||||
|
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
|
||||||
|
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
|
||||||
|
self.assertEqual(_web._limited, {})
|
||||||
|
self.assertEqual(fdroid.user_repos(), [])
|
||||||
|
|
||||||
|
def test_signature_checks_still_apply(self):
|
||||||
|
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
|
||||||
|
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
|
||||||
|
self.assertEqual(fdroid.user_repos(), [])
|
||||||
|
self.assertFalse(self.credentials_file().exists())
|
||||||
|
|
||||||
|
def test_cross_origin_redirect_never_gets_the_login(self):
|
||||||
|
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
|
||||||
|
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
|
||||||
|
source = self.add()
|
||||||
|
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
|
||||||
|
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
|
||||||
|
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
|
||||||
|
self.assertEqual(mirror, [None])
|
||||||
|
self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either
|
||||||
|
|
||||||
|
def test_traversal_names_are_refused_and_never_get_the_login(self):
|
||||||
|
for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk',
|
||||||
|
'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'):
|
||||||
|
with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'):
|
||||||
|
fdroid._child(REPO, name)
|
||||||
|
self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'),
|
||||||
|
REPO + 'app/en-US/phoneScreenshots/%230%20a.png')
|
||||||
|
# Even if such a URL reached the downloader directly, the login stays home.
|
||||||
|
login = _credentials.basic(REPO, USER, PASSWORD)
|
||||||
|
for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'):
|
||||||
|
with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.web.seen.clear()
|
||||||
|
with contextlib.suppress(SourceError): # the repository answers 401 without the login
|
||||||
|
fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login)
|
||||||
|
self.assertEqual(self.web.seen, [(url, None)])
|
||||||
|
|
||||||
|
def test_same_origin_redirect_outside_the_repository_gets_no_login(self):
|
||||||
|
self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'}
|
||||||
|
source = self.add()
|
||||||
|
self.web.seen.clear()
|
||||||
|
self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified'])
|
||||||
|
self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login),
|
||||||
|
('https://repo.example/elsewhere/example1.apk', None)])
|
||||||
|
|
||||||
|
def test_other_ports_and_http_never_get_the_login(self):
|
||||||
|
login = _credentials.basic(REPO, USER, PASSWORD)
|
||||||
|
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
|
||||||
|
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
|
||||||
|
self.assertFalse(login.matches(url), url)
|
||||||
|
self.assertTrue(login.matches('https://REPO.example:443/anything'))
|
||||||
|
self.assertNoSecrets(repr(login))
|
||||||
|
handler = _credentials.Handler(login)
|
||||||
|
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
|
||||||
|
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
|
||||||
|
|
||||||
|
def test_http_and_bad_logins_are_refused_before_any_request(self):
|
||||||
|
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
|
||||||
|
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
|
||||||
|
(REPO, {'username': 'a:b', 'password': PASSWORD}),
|
||||||
|
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
|
||||||
|
(REPO, {'password': PASSWORD}),
|
||||||
|
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
|
||||||
|
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
|
||||||
|
fdroid.add_repo(url, PIN, **kw)
|
||||||
|
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
|
||||||
|
self.assertNoSecrets(str(caught.exception))
|
||||||
|
self.assertEqual(self.web.seen, [])
|
||||||
|
|
||||||
|
def test_remove_deletes_the_login(self):
|
||||||
|
source = self.add()
|
||||||
|
other = _credentials.bearer('https://api.github.com/', TOKEN)
|
||||||
|
_credentials.put('github', other)
|
||||||
|
fdroid.remove_repo(source['id'])
|
||||||
|
self.assertFalse(_credentials.has(source['id']))
|
||||||
|
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
|
||||||
|
_credentials.remove('github')
|
||||||
|
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
|
||||||
|
|
||||||
|
def test_cli_reads_the_password_from_stdin(self):
|
||||||
|
out = io.StringIO()
|
||||||
|
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
|
||||||
|
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
|
||||||
|
contextlib.redirect_stdout(out):
|
||||||
|
fdroid.main()
|
||||||
|
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
|
||||||
|
self.assertNoSecrets(out.getvalue())
|
||||||
|
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
|
||||||
|
|
||||||
|
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
|
||||||
|
source = self.add()
|
||||||
|
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
|
||||||
|
self.assertTrue(_credentials.has(source['id']))
|
||||||
|
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
|
||||||
|
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubToken(Base):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.accept = {TOKEN}
|
||||||
|
self.releases = json.dumps([]).encode()
|
||||||
|
self.anonymous_limited = False
|
||||||
|
|
||||||
|
def route(self, url, auth):
|
||||||
|
if url.startswith('https://api.github.com/'):
|
||||||
|
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
|
||||||
|
return 401, {}, b'{"message": "Bad credentials"}'
|
||||||
|
if auth is None and self.anonymous_limited:
|
||||||
|
return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}'
|
||||||
|
if url.endswith('/redirected'):
|
||||||
|
return 302, {'Location': 'https://github.com/landed'}, b''
|
||||||
|
return 200, {}, self.releases
|
||||||
|
if url == 'https://github.com/landed':
|
||||||
|
return 200, {}, b'{"ok": true}'
|
||||||
|
raise AssertionError('unexpected request ' + url)
|
||||||
|
|
||||||
|
def test_saved_token_goes_only_to_the_api(self):
|
||||||
|
github.set_token(TOKEN)
|
||||||
|
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
||||||
|
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
|
||||||
|
auth = github._credential(github._token()[0])
|
||||||
|
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
|
||||||
|
b'{"ok": true}')
|
||||||
|
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
|
||||||
|
('https://github.com/landed', None)]) # redirect: no token
|
||||||
|
status = github.sources()[0]['token']
|
||||||
|
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
|
||||||
|
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
|
||||||
|
if os.name != 'nt':
|
||||||
|
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_rejected_token_retries_once_without_it(self):
|
||||||
|
github.set_token(TOKEN)
|
||||||
|
self.accept = set()
|
||||||
|
self.assertEqual(github._api('/repos/a/b/releases'), [])
|
||||||
|
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
|
||||||
|
self.assertTrue(github.token_status()['rejected'])
|
||||||
|
self.assertIn('rejected', self.stderr.getvalue())
|
||||||
|
self.assertNoSecrets(self.stderr.getvalue())
|
||||||
|
self.web.seen.clear()
|
||||||
|
github._api('/repos/a/b/other')
|
||||||
|
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
|
||||||
|
github.set_token(TOKEN) # saving it again gives it a fresh chance
|
||||||
|
self.assertFalse(github.token_status()['rejected'])
|
||||||
|
|
||||||
|
def test_environment_variable_wins(self):
|
||||||
|
github.set_token(TOKEN)
|
||||||
|
env = 'ghp_fromTheEnvironment'
|
||||||
|
self.accept = {env}
|
||||||
|
with patch.dict(os.environ, {github.ENV: env}):
|
||||||
|
github._api('/repos/a/b/releases')
|
||||||
|
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
|
||||||
|
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
|
||||||
|
self.assertEqual(github.token_status()['from'], 'saved')
|
||||||
|
|
||||||
|
def test_clear_removes_it_from_disk(self):
|
||||||
|
github.set_token(TOKEN)
|
||||||
|
github.clear_token()
|
||||||
|
self.assertFalse(self.credentials_file().exists())
|
||||||
|
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
|
||||||
|
github._api('/repos/a/b/releases')
|
||||||
|
self.assertEqual(self.web.seen[-1][1], None)
|
||||||
|
|
||||||
|
def test_saving_a_token_escapes_the_anonymous_rate_limit(self):
|
||||||
|
self.anonymous_limited = True
|
||||||
|
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
|
||||||
|
github._api('/repos/a/b/releases')
|
||||||
|
self.assertEqual(self.web.seen[-1][1], None)
|
||||||
|
github.set_token(TOKEN)
|
||||||
|
self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota
|
||||||
|
self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN))
|
||||||
|
github.clear_token()
|
||||||
|
count = len(self.web.seen)
|
||||||
|
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
|
||||||
|
github._api('/repos/a/b/third')
|
||||||
|
self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request
|
||||||
|
|
||||||
|
def test_bad_tokens_are_refused(self):
|
||||||
|
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
|
||||||
|
with self.subTest(token=token), self.assertRaises(SourceError):
|
||||||
|
github.set_token(token)
|
||||||
|
self.assertFalse(self.credentials_file().exists())
|
||||||
|
|
||||||
|
|
||||||
|
PNG = b'\x89PNG\r\n\x1a\nfixture'
|
||||||
|
PRIVATE = 'https://repo.example/private/repo/'
|
||||||
|
TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png',
|
||||||
|
PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png',
|
||||||
|
PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png',
|
||||||
|
'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png']
|
||||||
|
|
||||||
|
|
||||||
|
class ArtworkScope(Base):
|
||||||
|
"""The artwork proxy: actual outgoing headers, through _images.get's own HTTP code."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.sent, self.redirects = [], {}
|
||||||
|
_credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD))
|
||||||
|
test = self
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self, status, location=''):
|
||||||
|
self.status, self.location, self.body = status, location, PNG if status == 200 else b''
|
||||||
|
|
||||||
|
def getheader(self, name, default=''):
|
||||||
|
return self.location if name == 'Location' else default
|
||||||
|
|
||||||
|
def read1(self, n):
|
||||||
|
chunk, self.body = self.body[:n], self.body[n:]
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
def __init__(self, host, port, timeout=None):
|
||||||
|
self.host, self.port = host, port
|
||||||
|
|
||||||
|
def request(self, method, path, headers):
|
||||||
|
url = 'https://%s%s' % (self.host, path)
|
||||||
|
test.sent.append((url, headers.get('Authorization')))
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def getresponse(self):
|
||||||
|
if self.url in test.redirects:
|
||||||
|
return Response(302, test.redirects[self.url])
|
||||||
|
return Response(200)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# A public address, no real socket or TLS, and Connection in place of http.client's.
|
||||||
|
for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]),
|
||||||
|
(_images.socket, 'create_connection', lambda *a, **k: Mock()),
|
||||||
|
(_images.ssl, 'create_default_context', lambda: Mock()),
|
||||||
|
(_images.http.client, 'HTTPConnection', Connection)]:
|
||||||
|
mock = patch.object(target, name, value)
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
|
||||||
|
def test_artwork_in_the_repository_gets_the_login(self):
|
||||||
|
self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG)
|
||||||
|
self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))])
|
||||||
|
|
||||||
|
def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self):
|
||||||
|
for url in TRAVERSALS:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
self.sent.clear()
|
||||||
|
self.assertEqual(_images.get(url), PNG)
|
||||||
|
self.assertEqual(self.sent, [(url, None)])
|
||||||
|
self.assertIsNone(_credentials.for_url(url))
|
||||||
|
|
||||||
|
def test_redirect_to_a_traversal_gets_nothing(self):
|
||||||
|
start = PRIVATE + 'icons/a.png'
|
||||||
|
self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'
|
||||||
|
self.assertEqual(_images.get(start), PNG)
|
||||||
|
self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)])
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedHandler(Base):
|
||||||
|
"""Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included."""
|
||||||
|
|
||||||
|
def route(self, url, auth):
|
||||||
|
if url == PRIVATE + 'icons/a.png':
|
||||||
|
return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b''
|
||||||
|
return 200, {}, PNG
|
||||||
|
|
||||||
|
def test_redirect_into_a_traversal_is_stripped(self):
|
||||||
|
login = _credentials.basic(PRIVATE, USER, PASSWORD)
|
||||||
|
opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True))
|
||||||
|
with opener.open(PRIVATE + 'icons/a.png') as r:
|
||||||
|
self.assertEqual(r.read(), PNG)
|
||||||
|
self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)),
|
||||||
|
(PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)])
|
||||||
|
for url in TRAVERSALS:
|
||||||
|
self.assertFalse(_credentials.in_scope(login, url), url)
|
||||||
|
self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1'))
|
||||||
|
self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/')))
|
||||||
|
|
||||||
|
|
||||||
|
CHURN = '''
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
from apk_sources import _credentials
|
||||||
|
for i in range(int(sys.argv[3])):
|
||||||
|
key = sys.argv[2] + str(i)
|
||||||
|
_credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p'))
|
||||||
|
if i % 2:
|
||||||
|
_credentials.remove(key)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class TwoProcesses(Base):
|
||||||
|
def test_interleaved_saves_and_removals_are_not_lost(self):
|
||||||
|
ui = str(Path(__file__).resolve().parents[1] / 'ui')
|
||||||
|
env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data'))
|
||||||
|
count = 60
|
||||||
|
procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')]
|
||||||
|
for p in procs:
|
||||||
|
out, err = p.communicate(timeout=120)
|
||||||
|
self.assertEqual(p.returncode, 0, err.decode())
|
||||||
|
saved = set(json.loads(self.credentials_file().read_text()))
|
||||||
|
self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)})
|
||||||
|
if os.name != 'nt':
|
||||||
|
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which('node'), 'needs node')
|
||||||
|
class PageLogin(unittest.TestCase):
|
||||||
|
def test_login_leaves_the_address_field_before_sending(self):
|
||||||
|
"""tests/page/source_login.mjs runs the real page functions from index.html."""
|
||||||
|
r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||||
|
self.assertIn('ok', r.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class Endpoints(Repository):
|
||||||
|
"""The server API: logins in, never out, in responses or the server log."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
|
||||||
|
mock.start()
|
||||||
|
self.addCleanup(mock.stop)
|
||||||
|
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||||
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||||
|
self.addCleanup(self.httpd.server_close)
|
||||||
|
self.addCleanup(self.httpd.shutdown)
|
||||||
|
|
||||||
|
def request(self, method, path, body=None):
|
||||||
|
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
|
||||||
|
c.request(method, path, json.dumps(body) if body is not None else None,
|
||||||
|
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
|
||||||
|
r = c.getresponse()
|
||||||
|
raw = r.read().decode()
|
||||||
|
c.close()
|
||||||
|
self.replies.append(raw)
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
|
||||||
|
def job(self, reply):
|
||||||
|
for _ in range(500):
|
||||||
|
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
|
||||||
|
if job['done']:
|
||||||
|
return job
|
||||||
|
time.sleep(.01)
|
||||||
|
self.fail('job did not finish')
|
||||||
|
|
||||||
|
def test_add_with_login_and_token_settings(self):
|
||||||
|
self.replies = []
|
||||||
|
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
||||||
|
'username': USER, 'password': 'wrong'})[1])
|
||||||
|
self.assertIn('check the username and password', bad['error'])
|
||||||
|
self.assertEqual(fdroid.user_repos(), [])
|
||||||
|
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
|
||||||
|
'username': USER, 'password': PASSWORD})
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
job = self.job(reply)
|
||||||
|
self.assertIsNone(job['error'])
|
||||||
|
listed = self.request('GET', '/api/sources')[1]['sources']
|
||||||
|
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
|
||||||
|
self.assertEqual((repo['url'], repo['login']), (REPO, True))
|
||||||
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
|
||||||
|
'token': TOKEN})[0], 200)
|
||||||
|
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
|
||||||
|
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
|
||||||
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
|
||||||
|
'token': TOKEN})[0], 400)
|
||||||
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
|
||||||
|
self.assertFalse(_credentials.has('github'))
|
||||||
|
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
|
||||||
|
self.assertFalse(self.credentials_file().exists())
|
||||||
|
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
|
||||||
|
|
||||||
|
def test_login_in_the_url_is_accepted(self):
|
||||||
|
self.replies = []
|
||||||
|
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
|
||||||
|
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
|
||||||
|
self.assertIsNone(job['error'])
|
||||||
|
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
|
||||||
|
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
places = s._tmp_places()
|
||||||
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
|
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.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:
|
for folder, prefix in places:
|
||||||
folder.mkdir(parents=True, exist_ok=True)
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
|
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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
const test=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||||
|
const deferred=()=>{let resolve,reject;const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject};};
|
||||||
|
function installFixture() {
|
||||||
|
const f=workspaceFixture();
|
||||||
|
Object.assign(f.context,{wi:{queue:[]},wiNext(){f.calls.push(['review']);},sendFiles:files=>f.calls.push(['files',files])});
|
||||||
|
const start=html.indexOf('function openInstallChooser()'),end=html.indexOf('function renderHome(',start);
|
||||||
|
f.run(html.slice(start,end));return f;
|
||||||
|
}
|
||||||
|
test('Add / Install retains cancellation and hands chosen files to the existing transfer flow',()=>{
|
||||||
|
const f=installFixture(),input=f.el('installFileInput');let clicks=0;
|
||||||
|
input.click=()=>clicks++;f.run('openInstallChooser()');f.el('installChooseFile').onclick();assert.equal(clicks,1);
|
||||||
|
input.files=[];input.onchange();assert.equal(f.el('installChooser').open,true);assert.equal(f.calls.length,0);
|
||||||
|
const files=[{name:'game.zip'},{name:'player.apk'}];input.files=files;input.value='chosen';input.onchange();
|
||||||
|
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.location.hash,'#transfer');assert.equal(input.value,'');
|
||||||
|
assert.deepEqual(Array.from(f.calls[0][1]),files);
|
||||||
|
});
|
||||||
|
test('chooser destinations reapply explicit views when their hash is already active',()=>{
|
||||||
|
const cases=[['#appStore',"selectApps('installed')",f=>assert.equal(f.el('appStore').hidden,false)],
|
||||||
|
['#linuxApps',"selectApps('browse')",f=>assert.equal(f.el('appsLinux').hidden,false)],
|
||||||
|
['#getgames',"selectLibrary('all','installed')",f=>assert.equal(f.context.gm.src,'owned')],
|
||||||
|
['#steamStore',"selectLibrary('all','owned')",f=>assert.equal(f.context.gm.src,'store')]];
|
||||||
|
for(const [hash,change,verify] of cases){
|
||||||
|
const f=installFixture();f.navigate(hash);f.run(change);f.run('openInstallChooser()');let prevented=false;
|
||||||
|
f.el('installChooser').onclick({target:{closest:()=>({getAttribute:()=>hash})},preventDefault(){prevented=true;}});
|
||||||
|
assert.equal(prevented,true,hash);assert.equal(f.el('installChooser').open,false);verify(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('download links accept supported direct and manifest forms and reject malformed handoffs',()=>{
|
||||||
|
const f=installFixture();
|
||||||
|
for(const [raw,expected] of [
|
||||||
|
[' https://example.com/game.zip ',{kind:'url',target:'https://example.com/game.zip'}],
|
||||||
|
['frame-control://install?url=https%3A%2F%2Fexample.com%2Fapp.apk',{kind:'url',target:'https://example.com/app.apk'}],
|
||||||
|
['frame-control://install?manifest=https%3A%2F%2Fexample.com%2Fgame.json',{kind:'manifest',target:'https://example.com/game.json'}]
|
||||||
|
]){f.context.raw=raw;assert.deepEqual(JSON.parse(JSON.stringify(f.run('installLinkRequest(raw)'))),expected);}
|
||||||
|
for(const raw of ['file:///tmp/app.apk','javascript:alert(1)','https://user:secret@example.com/a.apk',
|
||||||
|
'frame-control://other?url=https://example.com/a.apk','frame-control://install/path?url=https://example.com/a.apk',
|
||||||
|
'frame-control://install?url=https://example.com/a.apk&manifest=https://example.com/a.json',
|
||||||
|
'frame-control://install?url=https://example.com/a.apk&url=https://example.com/b.apk',
|
||||||
|
'frame-control://install?manifest=garbage','not a URL','https://example.com/'+ 'x'.repeat(2050)]){
|
||||||
|
f.context.raw=raw;assert.throws(()=>f.run('installLinkRequest(raw)'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('link form queues a review only, with inline errors and a bounded waiting queue',()=>{
|
||||||
|
const f=installFixture(),submit=()=>f.el('installLinkForm').onsubmit({preventDefault(){}});
|
||||||
|
f.run('openInstallChooser()');f.el('installUrl').value='invalid';submit();
|
||||||
|
assert.equal(f.el('installChooser').open,true);assert.equal(f.el('installLinkError').hidden,false);assert.equal(f.context.wi.queue.length,0);assert.equal(f.calls.length,0);
|
||||||
|
f.el('installUrl').value='https://example.com/game.zip';submit();
|
||||||
|
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.wi.queue.length,1);assert.deepEqual(f.calls,[['review']]);
|
||||||
|
f.context.wi.queue=Array(5).fill({kind:'url',target:'https://example.com/game.zip'});f.run('openInstallChooser()');submit();
|
||||||
|
assert.equal(f.el('installChooser').open,true);assert.equal(f.context.wi.queue.length,5);assert.match(f.el('installLinkError').textContent,/Finish or close/);
|
||||||
|
});
|
||||||
|
test('link review checks metadata but starts no download before the existing confirmation',async()=>{
|
||||||
|
const f=installFixture(),writes=[];
|
||||||
|
Object.assign(f.context,{log(){},gb:n=>String(n),api:async(path,body)=>{writes.push({path,body});return {id:'plan-1',name:'Game',host:'example.com',linkHost:'example.com',file:'game.zip',kindLabel:'PC game',size:100};}});
|
||||||
|
f.el('wiGo').focus=()=>{};f.el('wiDlg').addEventListener=()=>{};
|
||||||
|
const start=html.indexOf('const wi ='),end=html.indexOf('async function wiPoll(',start);f.run(html.slice(start,end));
|
||||||
|
f.run("wi.queue.push({kind:'url',target:'https://example.com/game.zip'})");await f.run('wiNext()');
|
||||||
|
assert.equal(writes.length,1);assert.equal(writes[0].path,'/api/webinstall/check');assert.equal(f.el('wiDlg').open,true);assert.equal(f.el('wiGo').disabled,false);
|
||||||
|
const cancel=html.indexOf('$("wiCancel").onclick = async () =>');f.run(html.slice(cancel,html.indexOf('// Escape',cancel)));
|
||||||
|
await f.el('wiCancel').onclick();assert.equal(f.el('wiDlg').open,false);assert.equal(writes.length,1);
|
||||||
|
});
|
||||||
|
test('catalog installs retain progress, failure retry and installed-app handoff',async()=>{
|
||||||
|
const f=workspaceFixture(),offer={source:'fdroid',id:'org.player',package:'org.player',name:'Player',downloadable:true,version_code:42},writes=[];
|
||||||
|
let success=false,refreshes=0,opened;
|
||||||
|
Object.assign(f.context,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',openInstalledApp:pkg=>opened=pkg,
|
||||||
|
api:async(path,body)=>{writes.push({path,body});return {job:'job-1'};},loadAndroid:()=>refreshes++,
|
||||||
|
runJob:async(label,key,start,progress)=>{await start();progress({stage:'Installing',percent:75});assert.equal(f.el('detailProgress').value,75);return success?{package:'org.player'}:undefined;}});
|
||||||
|
const start=html.indexOf('function updateInstall()'),end=html.indexOf('async function openStoreApp(',start);f.run(html.slice(start,end));
|
||||||
|
const install=html.indexOf('async function installStoreApp()');f.run(html.slice(install,html.indexOf('async function changeSource(',install)));
|
||||||
|
await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Try again');assert.match(f.el('detailInstallNote').textContent,/couldn't finish/);
|
||||||
|
success=true;await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Open in Steam');assert.equal(refreshes,1);
|
||||||
|
assert.equal(writes.length,2);assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/sources/install',body:{source:'fdroid',id:'org.player',version_code:42}});
|
||||||
|
f.context.sourceState.jobs.clear();f.context.androidApps=[{package:'org.player'}];f.el('appDetail').showModal();f.run('updateInstall()');
|
||||||
|
assert.equal(f.el('detailInstall').textContent,'View installed app');await f.run('installStoreApp()');assert.equal(opened,'org.player');assert.equal(writes.length,2);assert.equal(f.el('appDetail').open,false);
|
||||||
|
});
|
||||||
|
test('Steam actions distinguish owned installation, installed play and non-owned store handoff',()=>{
|
||||||
|
const f=workspaceFixture();
|
||||||
|
f.context.gm.byId=new Map([[1,{installed:false}],[2,{installed:true}]]);
|
||||||
|
const start=html.indexOf('function gameButtons(');f.run(html.slice(start,html.indexOf('function moreGames(',start)));
|
||||||
|
assert.match(f.run("gameButtons(1,'Owned',false)"),/data-gm="install"/);
|
||||||
|
assert.match(f.run("gameButtons(2,'Installed',false)"),/data-launch="2"/);
|
||||||
|
for(const free of [false,true]){const output=f.run(`gameButtons(3,'Store',${free})`);assert.match(output,free?/Get on Steam/:/Buy on Steam/);assert.doesNotMatch(output,/data-gm="install"/);}
|
||||||
|
f.context.gm.dl={appid:1,percent:30};assert.match(f.run("gameButtons(1,'Owned',false)"),/Downloading 30%/);
|
||||||
|
});
|
||||||
|
test('Apps Games Tools Settings expose installed, browse, ownership and intentional legacy routes',()=>{
|
||||||
|
const f=workspaceFixture();
|
||||||
|
assert.deepEqual(f.links.map(a=>a.getAttribute()),['#apps','#games','#tools','#settings']);
|
||||||
|
f.navigate('');assert.equal(f.activePage,'apps');assert.equal(f.el('androidLibrary').hidden,false);assert.equal(f.el('appStore').hidden,true);
|
||||||
|
f.navigate('#games');assert.equal(f.activePage,'games');assert.equal(f.el('getgames').hidden,true);assert.equal(f.el('library').hidden,false);assert.equal(f.el('sideloaded').hidden,false);
|
||||||
|
f.navigate('#getgames');assert.equal(f.el('getgames').hidden,false);assert.equal(f.el('library').hidden,true);assert.equal(f.context.gm.src,'owned');
|
||||||
|
f.navigate('#steamStore');assert.equal(f.context.gm.src,'store');assert.equal(f.el('getgames').hidden,false);
|
||||||
|
f.navigate('#appStore');assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('androidLibrary').hidden,true);
|
||||||
|
f.navigate('#linuxApps');assert.equal(f.activePage,'apps');assert.equal(f.el('appsLinux').hidden,false);
|
||||||
|
f.navigate('#androidLibrary');assert.equal(f.el('appsLinux').hidden,true);assert.equal(f.el('androidLibrary').hidden,false);
|
||||||
|
f.navigate('#display');assert.equal(f.activePage,'settings');assert.equal(f.el('androidSettings').hidden,false);assert.equal(f.el('settingsDirectory').hidden,true);
|
||||||
|
f.navigate('#settings');assert.equal(f.el('settingsDirectory').hidden,false);assert.equal(f.el('settingsWorkspace').hidden,true);
|
||||||
|
for(const [route,page,primary] of [['#devices','devices','settings'],['#control','control','settings'],['#shots','capture','tools'],['#view','capture','tools'],['#pad','tools','tools'],['#comfort','tools','tools'],['#library','games','games'],['#discover','apps','apps'],['#privacy','settings','settings']]){
|
||||||
|
f.navigate(route);assert.equal(f.activePage,page,route);assert.equal(f.run('primaryPage(page)'),primary,route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('app views and game scopes restore separate scroll positions, queries and filters',()=>{
|
||||||
|
const f=workspaceFixture();f.navigate('#appStore');f.el('sourceQ').value='video';f.scroll(480);
|
||||||
|
f.run("selectApps('installed')");f.el('libQ').value='player';f.scroll(220);
|
||||||
|
f.navigate('#games');f.scroll(160);f.navigate('#apps');assert.equal(f.context.window.scrollY,220);
|
||||||
|
f.run("selectApps('browse')");assert.equal(f.context.window.scrollY,480);assert.equal(f.el('sourceQ').value,'video');assert.equal(f.el('libQ').value,'player');
|
||||||
|
f.navigate('#games');assert.equal(f.context.window.scrollY,160);
|
||||||
|
f.run("selectLibrary('steam')");f.scroll(600);f.run("selectLibrary('sideloaded')");f.scroll(80);f.run("selectLibrary('steam')");assert.equal(f.context.window.scrollY,600);
|
||||||
|
f.context.location.hash='#apps';f.scroll(0);f.run('showPage()');f.navigate('#games');assert.equal(f.context.window.scrollY,600);
|
||||||
|
});
|
||||||
|
test('Android browse opens Apps Browse after visiting the Steam store',()=>{
|
||||||
|
const f=workspaceFixture();f.navigate('#steamStore');f.navigate('#androidLibrary');
|
||||||
|
const start=html.indexOf('function showAndroidTab(tab)'),end=html.indexOf("document.querySelectorAll('[data-store-tab]')",start);
|
||||||
|
f.run(html.slice(start,end));f.run("showAndroidTab('browse')");f.run('showPage()');
|
||||||
|
assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);
|
||||||
|
});
|
||||||
|
test('empty installed library opens Browse even when its route is already in the address bar',()=>{
|
||||||
|
const f=workspaceFixture();let click;
|
||||||
|
f.context.libState={q:'',filter:'all'};f.el('andApps').addEventListener=(name,fn)=>click=fn;
|
||||||
|
const helper=html.indexOf('function showAndroidTab(tab)');
|
||||||
|
f.run(html.slice(helper,html.indexOf("document.querySelectorAll('[data-store-tab]')",helper)));
|
||||||
|
const start=html.indexOf('function renderLibrary()');
|
||||||
|
f.run(html.slice(start,html.indexOf('document.addEventListener("click",',start)));
|
||||||
|
f.navigate('#appStore');f.run("selectApps('installed');renderLibrary()");
|
||||||
|
assert.match(f.el('andApps').innerHTML,/data-store-go="browse"/);assert.equal(f.el('appStore').hidden,true);
|
||||||
|
click({target:{closest:()=>({dataset:{storeGo:'browse'}})}});
|
||||||
|
assert.equal(f.context.location.hash,'#appStore');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('appsInstalled').hidden,true);
|
||||||
|
});
|
||||||
|
test('owned scope excludes installed games and keeps unknown compatibility visible',()=>{
|
||||||
|
const f=workspaceFixture(),gm=f.context.gm;
|
||||||
|
Object.assign(gm,{owned:[{id:1,installed:true,name:'Installed',frame:3,sort:'installed'},{id:2,installed:false,name:'Owned unknown',frame:0,sort:'owned',lastPlayed:0}],q:'',filters:{owned:new Set(['0','1','2','3'])},shown:0});
|
||||||
|
f.context.moreGames=()=>{};
|
||||||
|
const start=html.indexOf('function filterGames('),end=html.indexOf('function price(',start);
|
||||||
|
f.run(html.slice(start,end));f.run('filterGames()');
|
||||||
|
assert.deepEqual(Array.from(gm.results,g=>g.id),[2]);
|
||||||
|
});
|
||||||
|
test('installed load succeeds while optional catalog is unresolved, and errors keep local retry',async()=>{
|
||||||
|
const f=workspaceFixture(),pending=deferred();
|
||||||
|
let result={apps:[{package:'org.example.player',label:'Player',running:false}]},fail=false;
|
||||||
|
f.context.api=path=>path==='/api/android'?fail?Promise.reject(Error('Temporary network failure')):Promise.resolve(result):pending.promise;
|
||||||
|
f.context.renderLibrary=()=>f.el('andApps').innerHTML='installed';
|
||||||
|
f.run('let androidSeq=0; const androidPending=new Set(); let installedPackage=null;');
|
||||||
|
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||||
|
f.run(html.slice(start,end));await f.run('loadAndroid()');
|
||||||
|
assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,true);
|
||||||
|
fail=true;await f.run('loadAndroid()');assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,false);
|
||||||
|
assert.match(f.el('androidLoadMessage').textContent,/Temporary network failure/);
|
||||||
|
fail=false;await f.run('loadAndroid()');assert.equal(f.el('androidLoadError').hidden,true);
|
||||||
|
assert.ok(html.includes('loadAndroid();\n\n// ---- Android display'),'startup must not wait for catalog completion');
|
||||||
|
});
|
||||||
|
test('older installed responses cannot replace a newer read',async()=>{
|
||||||
|
const f=workspaceFixture(),old=deferred(),next=deferred();let calls=0;
|
||||||
|
f.context.api=()=>++calls===1?old.promise:next.promise;f.context.renderLibrary=()=>{};
|
||||||
|
f.run('let androidSeq=0;const androidPending=new Set();let installedPackage=null;');
|
||||||
|
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||||
|
f.run(html.slice(start,end));const a=f.run('loadAndroid()'),b=f.run('loadAndroid()');
|
||||||
|
next.resolve({apps:[{package:'new'}]});await b;old.resolve({apps:[{package:'old'}]});await a;
|
||||||
|
assert.equal(f.context.androidApps[0].package,'new');
|
||||||
|
});
|
||||||
|
test('Android Open dispatch is deduplicated while pending and returns to an enabled row',async()=>{
|
||||||
|
const f=workspaceFixture(),request=deferred(),writes=[];
|
||||||
|
Object.assign(f.context,{devGen:1,installing:new Set(),toast(){},act:async(label,work)=>work(),
|
||||||
|
api:(path,body)=>{writes.push({path,body});return request.promise;},loadAndroid:async()=>{},renderLibrary(){},cat:{apps:null},loadReports(){}});
|
||||||
|
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||||
|
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||||
|
const end=html.indexOf('loadAndroid();\n\n// ---- Android display',start);
|
||||||
|
f.run(html.slice(start,end));
|
||||||
|
const button={dataset:{and:'launch',pkg:'org.example.player',name:'Player'},disabled:false,textContent:'Open'};
|
||||||
|
const event={target:{closest:()=>button}};
|
||||||
|
const a=f.listeners['body-click'](event);await f.listeners['body-click'](event);
|
||||||
|
assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');assert.equal(writes.length,1);
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/android',body:{action:'launch',package:'org.example.player'}});
|
||||||
|
request.resolve({message:'Opened'});await a;assert.equal(button.disabled,false);assert.equal(button.textContent,'Open');assert.equal(button.ariaBusy,'false');
|
||||||
|
});
|
||||||
|
test('entering Apps Installed refreshes data without clearing cached rows',()=>{
|
||||||
|
const f=workspaceFixture();f.el('andApps').innerHTML='cached installed apps';f.navigate('#games');f.navigate('#apps');
|
||||||
|
assert.equal(f.calls.filter(c=>c[0]==='android').length,1);
|
||||||
|
f.run("selectApps('installed')");assert.equal(f.calls.filter(c=>c[0]==='android').length,2);
|
||||||
|
f.navigate('#settings');f.navigate('#apps');assert.equal(f.calls.filter(c=>c[0]==='android').length,3);
|
||||||
|
assert.equal(f.el('andApps').innerHTML,'cached installed apps');
|
||||||
|
});
|
||||||
|
test('actions from detail synchronize existing rows and stay pending through the follow-up read',async()=>{
|
||||||
|
for(const action of ['launch','stop']) {
|
||||||
|
const f=workspaceFixture(),request=deferred(),refresh=deferred();let writes=0,renders=0;
|
||||||
|
const makeButton=pkg=>({dataset:{and:action,pkg,name:'Player'},disabled:false,textContent:action==='stop'?'Stop':'Open'});
|
||||||
|
const detail=makeButton('org.example.player'),row=makeButton('org.example.player'),other=makeButton('org.other');
|
||||||
|
const originalQuery=f.context.document.querySelectorAll;
|
||||||
|
f.context.document.querySelectorAll=selector=>selector==='[data-and="launch"], [data-and="stop"]'?[row,detail,other]:originalQuery(selector);
|
||||||
|
Object.assign(f.context,{devGen:1,installing:new Set(),log(){},toast(){},api:()=>{writes++;return request.promise;},
|
||||||
|
loadAndroid:()=>refresh.promise,renderLibrary(){renders++;row.disabled=false;row.textContent=action==='stop'?'Stop':'Open';row.ariaBusy='false';},cat:{apps:null},loadReports(){}});
|
||||||
|
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||||
|
f.run(html.slice(html.indexOf('async function act('),html.indexOf('\nfunction setConn(')));
|
||||||
|
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||||
|
f.run(html.slice(start,html.indexOf('loadAndroid();\n\n// ---- Android display',start)));
|
||||||
|
f.el('installedDetail').showModal();const pending=f.listeners['body-click']({target:{closest:()=>detail}});f.el('installedDetail').close();
|
||||||
|
for(const button of [row,detail]){assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');}
|
||||||
|
assert.equal(other.disabled,false);await f.listeners['body-click']({target:{closest:()=>row}});assert.equal(writes,1);
|
||||||
|
request.resolve({message:'Done'});await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
assert.equal(row.disabled,true);assert.equal(detail.disabled,true);assert.equal(renders,0,'stay pending while refreshed app state is unresolved');
|
||||||
|
refresh.resolve();await pending;assert.equal(renders,1);assert.equal(row.disabled,false);assert.equal(detail.disabled,false);assert.equal(f.run('androidPending.size'),0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('regenerated Android rows and details keep the compact accessible pending state',()=>{
|
||||||
|
const f=workspaceFixture(),app={package:'org.example.player',label:'Player',running:false};
|
||||||
|
Object.assign(f.context,{androidApps:[app],storeIcon:()=>'<span>icon</span>'});
|
||||||
|
f.run("const androidPending=new Set(['org.example.player']);let installedPackage=null;");
|
||||||
|
const start=html.indexOf('function libCard(a)'),end=html.indexOf('function renderLibrary()',start);f.run(html.slice(start,end));
|
||||||
|
for(const running of [false,true]) {
|
||||||
|
app.running=running;const row=f.run('libCard(androidApps[0])');f.run("openInstalledApp('org.example.player')");
|
||||||
|
assert.match(row,/disabled aria-busy="true">Wait…<\/button>/);
|
||||||
|
assert.match(f.el('installedDetailBody').innerHTML,/disabled aria-busy="true">Wait…<\/button>/);
|
||||||
|
}
|
||||||
|
f.run('androidPending.clear()');const row=f.run('libCard(androidApps[0])');assert.doesNotMatch(row,/aria-busy="true"/);assert.match(row,/>Stop<\/button>/);
|
||||||
|
});
|
||||||
|
test('explicit Capture section links take priority over saved destination scroll',()=>{
|
||||||
|
const f=workspaceFixture();f.navigate('#capture');f.scroll(700);f.calls.length=0;
|
||||||
|
f.navigate('#shots');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='shots'));
|
||||||
|
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||||
|
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||||
|
});
|
||||||
|
test('viewer keyboard shortcuts act only in the Capture workspace',()=>{
|
||||||
|
const f=workspaceFixture(),events=[];let handler;
|
||||||
|
f.context.document.activeElement={tagName:'BODY'};
|
||||||
|
f.context.document.addEventListener=(name,callback)=>handler=callback;
|
||||||
|
Object.assign(f.context,{ctrl:{on:false},zoom:1,setZoom:n=>events.push(['zoom',n])});
|
||||||
|
f.el('ctrlBtn').click=()=>events.push(['control']);f.el('fullBtn').click=()=>events.push(['fullscreen']);
|
||||||
|
const start=html.indexOf('document.addEventListener("keydown", e => {',html.indexOf('$("canvas").addEventListener("pointerdown"'));
|
||||||
|
const end=html.indexOf('\n});',start)+4;f.run(html.slice(start,end));
|
||||||
|
f.navigate('#capture');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||||
|
f.navigate('#home');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||||
|
});
|
||||||
|
test('late Mac streaming responses cannot reveal a different tool panel',async()=>{
|
||||||
|
for(const rejects of [false,true]) {
|
||||||
|
const f=workspaceFixture(),response=deferred();
|
||||||
|
Object.assign(f.context,{api:()=>response.promise,clearTimeout(){},setTimeout(){},failed(){}});
|
||||||
|
f.run('let mvSeq=0,mvTimer=0;');
|
||||||
|
const start=html.indexOf('async function loadMacView(restart)'),end=html.indexOf('$("mvRefresh").onclick',start);
|
||||||
|
f.run(html.slice(start,end));f.context.location.hash='#macview';const pending=f.run('loadMacView()');
|
||||||
|
f.navigate('#transfer');assert.equal(f.el('macview').hidden,true);
|
||||||
|
rejects?response.reject(Error('Network failed')):response.resolve({available:true,streams:[],displays:[],windows:[]});
|
||||||
|
await pending;assert.equal(f.el('macview').hidden,true);assert.equal(f.el('transfer').hidden,false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Home task count includes the current Steam download',()=>{
|
||||||
|
const f=workspaceFixture();Object.assign(f.context,{state:{hostname:'Frame',battery:{percent:80}},stateRead:{valid:true},online:true});
|
||||||
|
f.context.gm.dl={appid:1,percent:42};f.run('renderHome()');
|
||||||
|
assert.equal(f.el('homeJobs').textContent,'1 task in progress');assert.match(f.el('homeDownload').textContent,/42%/);
|
||||||
|
});
|
||||||
|
test('a late screenshot read cannot replace newer navigation or clear a newer viewer request',async()=>{
|
||||||
|
const f=workspaceFixture(),old=deferred(),newer=deferred();let reads=0;
|
||||||
|
Object.assign(f.context,{shotBlob:()=>++reads===1?old.promise:newer.promise,URL:{createObjectURL:()=> 'blob:shot',revokeObjectURL(){}},
|
||||||
|
Image:class{set src(value){this.onload();}},draw(){},shotApp:()=> 'Game',toast(){},desk:{shown:null}});
|
||||||
|
const start=html.indexOf('async function openShot(s)'),end=html.indexOf('async function saveShots(',start);
|
||||||
|
f.run(html.slice(start,end));f.navigate('#capture');const pending=f.run("openShot({id:1,file:'one.png',time:1})");
|
||||||
|
assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||||
|
f.navigate('#library');f.scroll(240);assert.equal(f.el('viewer').classList.contains('busy'),false);
|
||||||
|
old.resolve({});await pending;assert.equal(f.activePage,'games');assert.equal(f.context.location.hash,'#library');assert.equal(f.context.window.scrollY,240);
|
||||||
|
f.navigate('#capture');const first=f.run("openShot({id:2,file:'two.png',time:1})");
|
||||||
|
f.navigate('#home');f.navigate('#capture');f.run('viewGen++');f.el('viewer').classList.toggle('busy',true);
|
||||||
|
newer.resolve({});await first;assert.equal(f.activePage,'capture');assert.equal(f.context.location.hash,'#capture');assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||||
|
});
|
||||||
|
test('Steam discovery preserves an unsubmitted query across a source change',()=>{
|
||||||
|
const f=workspaceFixture();Object.assign(f.context.gm,{src:'store',q:'owned query',storeDraft:'',storeQ:'submitted query'});
|
||||||
|
f.context.renderChips=()=>{};f.context.filterGames=()=>{};
|
||||||
|
const start=html.indexOf('function setGameSource(src)'),end=html.indexOf('$("gmSrc").onclick',start);
|
||||||
|
f.run(html.slice(start,end));f.el('gmQ').value='unsubmitted search';f.run("setGameSource('owned')");
|
||||||
|
assert.equal(f.el('gmQ').value,'owned query');f.run("setGameSource('store')");assert.equal(f.el('gmQ').value,'unsubmitted search');
|
||||||
|
});
|
||||||
|
test('expanded owned and store results survive source and destination returns',async()=>{
|
||||||
|
const f=workspaceFixture(),gm=f.context.gm;
|
||||||
|
const games=Array.from({length:120},(_,i)=>({id:i+1,name:'Game '+String(i+1).padStart(3,'0'),sort:String(i).padStart(3,'0'),frame:3,installed:false,lastPlayed:0}));
|
||||||
|
Object.assign(gm,{owned:games,store:games,storeQ:'game',storeDraft:'game',country:'US',q:'',results:[],filters:{owned:new Set(['3']),store:new Set(['3'])},seq:0});
|
||||||
|
Object.assign(f.context,{FLABEL:{3:'Verified'},FCLASS:{3:'verified'},CDN:'https://example.invalid',renderChips(){}});
|
||||||
|
f.el('gmGrid').insertAdjacentHTML=(position,s)=>f.el('gmGrid').innerHTML+=s;
|
||||||
|
const start=html.indexOf('function filterGames('),end=html.indexOf('$("gmSrc").onclick',start);
|
||||||
|
f.run(html.slice(start,end));
|
||||||
|
f.navigate('#getgames');f.run('filterGames();moreGames()');f.scroll(7000);
|
||||||
|
assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);
|
||||||
|
f.navigate('#steamStore');f.run('moreGames()');f.scroll(6500);
|
||||||
|
assert.equal(gm.shown,96);f.navigate('#getgames');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,7000);
|
||||||
|
f.navigate('#steamStore');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,6500);
|
||||||
|
f.run('filterGames()');assert.equal(gm.shown,48,'a deliberate filter change resets expansion');
|
||||||
|
const response=deferred();f.context.api=()=>response.promise;f.el('gmQ').value='new search';const search=f.run('searchStore()');
|
||||||
|
f.navigate('#getgames');const ownedMarkup=f.el('gmGrid').innerHTML;
|
||||||
|
response.resolve({results:games.slice(0,60)});await search;
|
||||||
|
assert.equal(f.el('gmGrid').innerHTML,ownedMarkup,'a late store response leaves current owned results intact');
|
||||||
|
f.navigate('#steamStore');assert.equal(gm.shown,48);assert.equal(gm.storeQ,'new search');
|
||||||
|
});
|
||||||
|
test('Home shortcuts show their tool before focusing it, so typing works on the first tap',()=>{
|
||||||
|
const f=workspaceFixture(),pad={state:'on'},buttons=[{dataset:{go:'pad'}}];let focusedVisible=null;
|
||||||
|
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||||
|
Object.assign(f.context,{pad,padStart(){},history:{pushState(_,__,url){f.context.location.hash=url;}}});
|
||||||
|
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
|
||||||
|
f.navigate('#home');
|
||||||
|
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
|
||||||
|
let shows=0;f.run('const realShowPage=showPage;');f.context.showPage=()=>{shows++;f.run('realShowPage()');};
|
||||||
|
f.run(html.slice(start,end).replace(/showPage\(\)/g,'globalThis.showPage()'));buttons[0].onclick();
|
||||||
|
assert.equal(focusedVisible,true);assert.equal(shows,1);assert.equal(f.context.location.hash,'#pad');
|
||||||
|
});
|
||||||
|
test('a Home shortcut keeps the destination scroll and loads its tool once, with frames deferred',()=>{
|
||||||
|
const f=workspaceFixture(),frames=[],events=[],buttons=[{dataset:{go:'pad'}},{dataset:{go:'macview'}}];
|
||||||
|
// A hash assignment queues hashchange, which a browser may deliver before the next frame.
|
||||||
|
let hash=f.context.location.hash;
|
||||||
|
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
|
||||||
|
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
|
||||||
|
f.context.requestAnimationFrame=fn=>frames.push(fn);
|
||||||
|
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
|
||||||
|
Object.assign(f.context,{pad:{state:'on'},padStart(){},history:{pushState(_,__,url){hash=url;}}});
|
||||||
|
f.el('padType').focus=()=>{};
|
||||||
|
f.navigate('#pad');flush();f.scroll(320);
|
||||||
|
f.navigate('#home');flush();f.scroll(150);
|
||||||
|
const start=html.indexOf('function showNow('),end=html.indexOf('$("nowSend").onclick',start);
|
||||||
|
f.run(html.slice(start,end));
|
||||||
|
buttons[0].onclick();flush();
|
||||||
|
assert.equal(f.context.window.scrollY,320);
|
||||||
|
f.navigate('#home');flush();f.calls.length=0;
|
||||||
|
buttons[1].onclick();flush();
|
||||||
|
assert.equal(f.calls.filter(c=>c[0]==='macview').length,1);
|
||||||
|
});
|
||||||
|
test('Cast keeps the Capture scroll and shows Capture once, with frames deferred',()=>{
|
||||||
|
const f=workspaceFixture(),frames=[],events=[];let shows=0;
|
||||||
|
let hash=f.context.location.hash;
|
||||||
|
Object.defineProperty(f.context.location,'hash',{get:()=>hash,set(v){const next='#'+String(v).replace(/^#/,'');if(next!==hash){hash=next;events.push(()=>f.run('showPage({type:"hashchange"})'));}}});
|
||||||
|
const flush=()=>{while(events.length||frames.length)(events.length?events:frames).shift()();};
|
||||||
|
f.context.requestAnimationFrame=fn=>frames.push(fn);
|
||||||
|
Object.assign(f.context,{history:{pushState(_,__,url){hash=url;}},setView(){},toggleLive(){}});
|
||||||
|
f.el('viewer').requestFullscreen=()=>Promise.resolve();
|
||||||
|
f.navigate('#capture');flush();f.scroll(320);
|
||||||
|
f.navigate('#home');flush();f.scroll(150);
|
||||||
|
const start=html.indexOf('function showNow('),end=html.indexOf('\n};\n',html.indexOf('$("castBtn").onclick'))+4;
|
||||||
|
f.run(html.slice(start,end).replace(/showPage\(\)/g,'(globalThis.__shows=(globalThis.__shows||0)+1,showPage())'));
|
||||||
|
f.el('castBtn').onclick();flush();
|
||||||
|
assert.equal(f.context.window.scrollY,320);assert.equal(f.run('globalThis.__shows'),1);assert.equal(hash,'#capture');
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Execute the production workspace code with only browser/transport boundaries replaced.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
|
||||||
|
function workspaceFixture() {
|
||||||
|
const elements = new Map(), listeners = {}, calls = [];
|
||||||
|
const classes = () => { const set=new Set(); return {add(k){set.add(k);},toggle(k,on){if(on===undefined)on=!set.has(k);on?set.add(k):set.delete(k);return on;},remove(k){set.delete(k);},contains:k=>set.has(k)}; };
|
||||||
|
function el(id) {
|
||||||
|
if (!elements.has(id)) elements.set(id,{id,hidden:false,disabled:false,open:false,innerHTML:'',textContent:'',value:'',dataset:{},classList:classes(),
|
||||||
|
setAttribute(k,v){this[k]=v;},removeAttribute(k){delete this[k];},querySelectorAll(){return [];},
|
||||||
|
append(node){node.parent=node.parentNode=this;},prepend(node){node.parent=node.parentNode=this;},scrollIntoView(){calls.push(['section',id]);},
|
||||||
|
showModal(){this.open=true;},close(){this.open=false;},addEventListener(k,fn){listeners[id+'-'+k]=fn;}});
|
||||||
|
return elements.get(id);
|
||||||
|
}
|
||||||
|
const pages=[...html.matchAll(/data-page="([^"]+)"/g)].map((m,i)=>Object.assign(el('page-'+i),{dataset:{page:m[1]}}));
|
||||||
|
const nav=html.match(/<nav id="nav"[^>]*>([\s\S]*?)<\/nav>/)[1];
|
||||||
|
const links=[...nav.matchAll(/<a href="([^"]+)"/g)].map((m,i)=>Object.assign(el('nav-'+i),{getAttribute:()=>m[1]}));
|
||||||
|
const controls={};
|
||||||
|
for (const [attr,values] of [['scope',['installed','owned','store']],['library-source',['all','steam','sideloaded']],['app-view',['installed','browse','linux']]]) {
|
||||||
|
const prop=attr.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
|
||||||
|
controls[`[data-${attr}]`]=values.map(v=>Object.assign(el(attr+'-'+v),{dataset:{[prop]:v}}));
|
||||||
|
}
|
||||||
|
const win={scrollY:0,scrollTo(a,b){this.scrollY=typeof a==='object'?a.top:b;calls.push(['scroll',this.scrollY]);},addEventListener(k,fn){listeners[k]=fn;}};
|
||||||
|
let hash='#apps';const location={get hash(){return hash;},set hash(value){hash=value&&!String(value).startsWith('#')?'#'+value:String(value);}};
|
||||||
|
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
|
||||||
|
window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
|
||||||
|
loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){},
|
||||||
|
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||||
|
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
|
||||||
|
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||||
|
vm.createContext(context);
|
||||||
|
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||||
|
vm.runInContext(html.slice(start,end),context);
|
||||||
|
return {html,context,elements,el,calls,pages,links,listeners,run:code=>vm.runInContext(code,context),
|
||||||
|
navigate(hash){context.location.hash=hash;vm.runInContext('showPage()',context);},
|
||||||
|
get activePage(){return pages.find(p=>p.classList.contains('on'))?.dataset.page;},
|
||||||
|
scroll(y){win.scrollY=y;listeners.scroll();}};
|
||||||
|
}
|
||||||
|
module.exports={workspaceFixture,html};
|
||||||
@@ -45,6 +45,10 @@ class SourceError(Exception):
|
|||||||
"""User-readable failure from a source (network, format, verification)."""
|
"""User-readable failure from a source (network, format, verification)."""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceAuthError(SourceError):
|
||||||
|
"""The server refused our login (HTTP 401, or 403 when a login was sent)."""
|
||||||
|
|
||||||
|
|
||||||
class SourceLimited(SourceError):
|
class SourceLimited(SourceError):
|
||||||
"""The source's host asked us to slow down; retry_after is in seconds."""
|
"""The source's host asked us to slow down; retry_after is in seconds."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
|
||||||
|
|
||||||
|
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
|
||||||
|
them for a headset instead: VR first, then games and the flat apps worth having in a panel
|
||||||
|
(video, reading, making things). Apps built around a phone's hardware or system (calls and
|
||||||
|
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
|
||||||
|
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# (id, label), in the order the store shows them.
|
||||||
|
CATEGORIES = (
|
||||||
|
('vr', 'VR'),
|
||||||
|
('games', 'Games'),
|
||||||
|
('emulators', 'Emulators'),
|
||||||
|
('watch', 'Watch & listen'),
|
||||||
|
('read', 'Read & learn'),
|
||||||
|
('create', 'Create'),
|
||||||
|
('work', 'Work'),
|
||||||
|
('web', 'Web & chat'),
|
||||||
|
('tools', 'Tools'),
|
||||||
|
)
|
||||||
|
IDS = {c for c, _ in CATEGORIES}
|
||||||
|
|
||||||
|
# F-Droid's category names -> a store category; None = only useful on a phone.
|
||||||
|
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
|
||||||
|
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
|
||||||
|
BROAD = {
|
||||||
|
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
|
||||||
|
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
|
||||||
|
'Development': 'tools', 'Security': 'tools',
|
||||||
|
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
|
||||||
|
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
|
||||||
|
'System': None, 'Theming': None,
|
||||||
|
}
|
||||||
|
FINE = {
|
||||||
|
'emulators': ('Emulator',),
|
||||||
|
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
|
||||||
|
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
|
||||||
|
'Strategy Game', 'Visual Novel', 'Word Game'),
|
||||||
|
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
|
||||||
|
'Ambient Sound'),
|
||||||
|
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
|
||||||
|
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
|
||||||
|
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
|
||||||
|
'Bookmark'),
|
||||||
|
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
|
||||||
|
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
|
||||||
|
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
|
||||||
|
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
|
||||||
|
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
|
||||||
|
}
|
||||||
|
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
|
||||||
|
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
|
||||||
|
PHONE_SYSTEM = {
|
||||||
|
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
|
||||||
|
'Theming', 'Emergency Action', 'Xposed', 'Automation',
|
||||||
|
}
|
||||||
|
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
|
||||||
|
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
|
||||||
|
# category says otherwise. A finer one when the source has them; a broad one when it only has
|
||||||
|
# broad ones (IzzyOnDroid often does).
|
||||||
|
PHONE_SIDE = {
|
||||||
|
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
|
||||||
|
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
|
||||||
|
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
|
||||||
|
'Connectivity',
|
||||||
|
}
|
||||||
|
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
|
||||||
|
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
|
||||||
|
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
|
||||||
|
'Meditation', 'Mental Health', 'Sports & Health'}
|
||||||
|
# When an app has several, the one that best describes it on a headset wins.
|
||||||
|
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
|
||||||
|
|
||||||
|
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
|
||||||
|
# What an app is, from its name: these are phone parts, whatever the categories say.
|
||||||
|
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
|
||||||
|
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
|
||||||
|
r'magisk|xposed|battery)\b|\[root\]', re.I)
|
||||||
|
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
|
||||||
|
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
|
||||||
|
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
|
||||||
|
# Needs hardware or a device the Frame doesn't have, from the name or summary.
|
||||||
|
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
|
||||||
|
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(entry):
|
||||||
|
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
|
||||||
|
if entry.get('vr') is True:
|
||||||
|
return {'category': 'vr', 'phone_only': False}
|
||||||
|
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
|
||||||
|
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
|
||||||
|
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
|
||||||
|
broad = {BROAD[c] for c in names if BROAD.get(c)}
|
||||||
|
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
|
||||||
|
if category is None and EMULATOR.search(name + ' ' + summary):
|
||||||
|
category = 'emulators'
|
||||||
|
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
|
||||||
|
useful = fine or (broad if only_broad else set())
|
||||||
|
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
|
||||||
|
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
|
||||||
|
for c in names):
|
||||||
|
phone = True
|
||||||
|
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
|
||||||
|
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
|
||||||
|
return {'category': category, 'phone_only': phone}
|
||||||
|
|
||||||
|
|
||||||
|
def matches(entry, category=None, phone=False):
|
||||||
|
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
|
||||||
|
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
|
||||||
|
if entry.get('phone_only') and not phone:
|
||||||
|
return False
|
||||||
|
return not category or entry.get('category') == category
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""Logins for sources behind one: HTTP basic auth and access tokens.
|
||||||
|
|
||||||
|
Saved apart from the source settings, in frame_host.data_dir('source-credentials.json')
|
||||||
|
(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each
|
||||||
|
credential belongs to one exact origin (https, host, port) and is only ever sent
|
||||||
|
to a request for that origin: Handler adds it to each request, redirects
|
||||||
|
included, and removes any Authorization header from every other request.
|
||||||
|
|
||||||
|
Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and
|
||||||
|
artwork use) is stricter: the URL must also be under the credential's scope by whole
|
||||||
|
path segments, with no dot segments, encoded slashes or ';' a server could normalise
|
||||||
|
into a path outside it. Origin-only matching suits a host-wide token (GitHub's API).
|
||||||
|
|
||||||
|
Secrets leave this module only as an Authorization header value. Credential's
|
||||||
|
repr hides them, and nothing here puts them in an exception message.
|
||||||
|
|
||||||
|
Each change is a read-modify-write under an inter-process lock (the CLI and the
|
||||||
|
app may both save), so one process can't undo another's save or removal.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
_lock = threading.RLock()
|
||||||
|
MAX = 4096 # longest username, password or token accepted
|
||||||
|
|
||||||
|
|
||||||
|
def _path():
|
||||||
|
return frame_host.data_dir('source-credentials.json')
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _changing():
|
||||||
|
"""This thread, then this process, holds the credentials file for a read-modify-write."""
|
||||||
|
path = _path().with_name('source-credentials.lock')
|
||||||
|
with _lock:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(str(path), 'a+b') as f:
|
||||||
|
if os.name == 'nt':
|
||||||
|
import msvcrt
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
f.seek(0)
|
||||||
|
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
|
||||||
|
break
|
||||||
|
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
|
||||||
|
if e.errno not in (errno.EACCES, errno.EDEADLK):
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
f.seek(0)
|
||||||
|
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def path_segments(path):
|
||||||
|
"""A URL path's decoded segments, or None if a server might read it as another path:
|
||||||
|
'.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding
|
||||||
|
(double encoding), or ';' path parameters."""
|
||||||
|
if ';' in path or '\\' in path:
|
||||||
|
return None
|
||||||
|
parts = [urllib.parse.unquote(part) for part in path.split('/')]
|
||||||
|
if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts):
|
||||||
|
return None
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def in_scope(credential, url):
|
||||||
|
"""True if url is on credential's origin and under its scope by whole, plain path segments."""
|
||||||
|
if credential is None or not credential.matches(url):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
parts, base = path_segments(path), path_segments(scope)
|
||||||
|
if parts is None or base is None:
|
||||||
|
return False
|
||||||
|
if base and base[-1] == '':
|
||||||
|
base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository'
|
||||||
|
return parts[:len(base)] == base
|
||||||
|
|
||||||
|
|
||||||
|
def origin(url):
|
||||||
|
"""'https://host:port' for an https URL; ValueError for anything else."""
|
||||||
|
p = urllib.parse.urlsplit(url)
|
||||||
|
port = p.port # raises ValueError on a bad port
|
||||||
|
if p.scheme.lower() != 'https' or not p.hostname:
|
||||||
|
raise ValueError('credentials are only sent over HTTPS')
|
||||||
|
host = p.hostname.lower()
|
||||||
|
return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443)
|
||||||
|
|
||||||
|
|
||||||
|
def _check(value, what, allow_empty=False):
|
||||||
|
if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX:
|
||||||
|
raise ValueError('invalid ' + what)
|
||||||
|
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||||||
|
raise ValueError(what + ' must not contain control characters')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Credential:
|
||||||
|
"""A username and password (basic) or a token (bearer) for one origin.
|
||||||
|
|
||||||
|
scope is the URL prefix it belongs under: in_scope checks it (by path segments),
|
||||||
|
matches() checks only the origin."""
|
||||||
|
__slots__ = ('origin', 'scope', 'username', '_secret')
|
||||||
|
|
||||||
|
def __init__(self, scope, secret, username=None):
|
||||||
|
self.origin = origin(scope)
|
||||||
|
self.scope = scope
|
||||||
|
if username is not None:
|
||||||
|
_check(username, 'username')
|
||||||
|
if ':' in username:
|
||||||
|
raise ValueError('username must not contain ":"')
|
||||||
|
_check(secret, 'password', allow_empty=True)
|
||||||
|
else:
|
||||||
|
_check(secret, 'token')
|
||||||
|
if any(c.isspace() for c in secret):
|
||||||
|
raise ValueError('token must not contain spaces')
|
||||||
|
self.username, self._secret = username, secret
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self):
|
||||||
|
return 'bearer' if self.username is None else 'basic'
|
||||||
|
|
||||||
|
def header(self):
|
||||||
|
if self.username is None:
|
||||||
|
return 'Bearer ' + self._secret
|
||||||
|
return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii')
|
||||||
|
|
||||||
|
def matches(self, url):
|
||||||
|
"""True only for a URL on exactly this credential's origin."""
|
||||||
|
try:
|
||||||
|
return origin(url) == self.origin
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def same(self, other):
|
||||||
|
return isinstance(other, Credential) and (self.origin, self.username, self._secret) == (
|
||||||
|
other.origin, other.username, other._secret)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Credential %s for %s>' % (self.kind, self.origin)
|
||||||
|
|
||||||
|
def _json(self):
|
||||||
|
return {'scope': self.scope, 'username': self.username, 'secret': self._secret}
|
||||||
|
|
||||||
|
|
||||||
|
def basic(scope, username, password):
|
||||||
|
"""Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw')."""
|
||||||
|
return Credential(scope, password, username)
|
||||||
|
|
||||||
|
|
||||||
|
def bearer(scope, token):
|
||||||
|
"""An access token for scope's origin, sent as 'Authorization: Bearer <token>'."""
|
||||||
|
return Credential(scope, token)
|
||||||
|
|
||||||
|
|
||||||
|
def _read():
|
||||||
|
try:
|
||||||
|
with open(str(_path()), encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError
|
||||||
|
return data
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {} # unreadable: as if none were saved; the next save rewrites it
|
||||||
|
|
||||||
|
|
||||||
|
def _save(data):
|
||||||
|
path = _path()
|
||||||
|
if not data:
|
||||||
|
try:
|
||||||
|
os.remove(str(path))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, separators=(',', ':'))
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, str(path))
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def get(key):
|
||||||
|
"""The saved Credential for key, or None."""
|
||||||
|
with _lock:
|
||||||
|
item = _read().get(key)
|
||||||
|
try:
|
||||||
|
return Credential(item['scope'], item['secret'], item.get('username'))
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has(key):
|
||||||
|
return get(key) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def put(key, credential):
|
||||||
|
if not isinstance(credential, Credential):
|
||||||
|
raise TypeError('put() needs a Credential')
|
||||||
|
with _changing():
|
||||||
|
data = _read()
|
||||||
|
data[key] = credential._json()
|
||||||
|
_save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(key):
|
||||||
|
"""Delete key's credential from disk; True if there was one."""
|
||||||
|
with _changing():
|
||||||
|
data = _read()
|
||||||
|
if key not in data:
|
||||||
|
return False
|
||||||
|
del data[key]
|
||||||
|
_save(data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def for_url(url):
|
||||||
|
"""A saved credential url is in the scope of (see in_scope), or None. Call it again for
|
||||||
|
each redirect hop: the answer depends only on the URL being requested."""
|
||||||
|
with _lock:
|
||||||
|
keys = list(_read())
|
||||||
|
for key in keys:
|
||||||
|
credential = get(key)
|
||||||
|
if in_scope(credential, url):
|
||||||
|
return credential
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(urllib.request.BaseHandler):
|
||||||
|
"""Adds the credential to requests for its origin (redirects included) and strips
|
||||||
|
Authorization from every other request, so a redirect elsewhere never gets it.
|
||||||
|
scoped=True also requires each request, redirects included, to be in_scope."""
|
||||||
|
handler_order = 900 # after the default processors
|
||||||
|
|
||||||
|
def __init__(self, credential, scoped=False):
|
||||||
|
self.credential, self.scoped = credential, scoped
|
||||||
|
|
||||||
|
def _request(self, req):
|
||||||
|
req.remove_header('Authorization')
|
||||||
|
allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url)
|
||||||
|
if allowed(self.credential, req.full_url):
|
||||||
|
req.add_unredirected_header('Authorization', self.credential.header())
|
||||||
|
return req
|
||||||
|
|
||||||
|
http_request = https_request = _request
|
||||||
+44
-13
@@ -9,10 +9,12 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from urllib.parse import urljoin, urlsplit
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
from apk_sources import SourceError
|
from apk_sources import SourceError, _credentials
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_urls = OrderedDict()
|
_urls = OrderedDict() # token -> URL, oldest first
|
||||||
|
_tokens = {} # URL -> token
|
||||||
|
MAX_TOKENS = 16384
|
||||||
_cache = OrderedDict()
|
_cache = OrderedDict()
|
||||||
MAX_IMAGE = 8 * 1024 * 1024
|
MAX_IMAGE = 8 * 1024 * 1024
|
||||||
MAX_CACHE = 64 * 1024 * 1024
|
MAX_CACHE = 64 * 1024 * 1024
|
||||||
@@ -31,24 +33,24 @@ def register(url):
|
|||||||
if not isinstance(url, str) or not valid_url(url):
|
if not isinstance(url, str) or not valid_url(url):
|
||||||
return None
|
return None
|
||||||
with _lock:
|
with _lock:
|
||||||
for token, known in _urls.items():
|
token = _tokens.get(url)
|
||||||
if known == url:
|
if token in _urls:
|
||||||
_urls.move_to_end(token)
|
_urls.move_to_end(token)
|
||||||
return '/source-image/' + token
|
return '/source-image/' + token
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
_urls[token] = url
|
_urls[token], _tokens[url] = url, token
|
||||||
while len(_urls) > 4096:
|
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
|
||||||
_urls.popitem(last=False)
|
_tokens.pop(_urls.popitem(last=False)[1], None)
|
||||||
return '/source-image/' + token
|
return '/source-image/' + token
|
||||||
|
|
||||||
|
|
||||||
def artwork(entry):
|
def artwork(entry, shots=12):
|
||||||
images = entry.get('images') or {}
|
images = entry.get('images') or {}
|
||||||
if not isinstance(images, dict):
|
if not isinstance(images, dict):
|
||||||
images = {}
|
images = {}
|
||||||
return {'icon': register(images.get('icon') or entry.get('icon')),
|
return {'icon': register(images.get('icon') or entry.get('icon')),
|
||||||
'banner': register(images.get('banner')),
|
'banner': register(images.get('banner')),
|
||||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
|
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
|
||||||
|
|
||||||
|
|
||||||
def image_type(data):
|
def image_type(data):
|
||||||
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
|||||||
|
|
||||||
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
||||||
_resolvers = threading.BoundedSemaphore(4)
|
_resolvers = threading.BoundedSemaphore(4)
|
||||||
|
# A page of store cards asks for dozens of images from the same few hosts at once: their public
|
||||||
|
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
|
||||||
|
_dns = {} # (host, port) -> (addresses, expires)
|
||||||
|
DNS_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
def _resolve(host, port, timeout):
|
def _resolve(host, port, timeout):
|
||||||
|
with _lock:
|
||||||
|
known = _dns.get((host, port))
|
||||||
|
if known and known[1] > time.monotonic():
|
||||||
|
return known[0]
|
||||||
|
found = _lookup(host, port, timeout)
|
||||||
|
try:
|
||||||
|
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
public = False
|
||||||
|
if public: # private answers are refused by the caller, and never kept
|
||||||
|
with _lock:
|
||||||
|
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
|
||||||
|
while len(_dns) > 256:
|
||||||
|
_dns.pop(next(iter(_dns)))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup(host, port, timeout):
|
||||||
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||||
if not _resolvers.acquire(blocking=False):
|
# A request waits for a free slot within its own budget rather than failing at once.
|
||||||
|
started = time.monotonic()
|
||||||
|
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
|
||||||
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
||||||
|
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
|
||||||
found = {}
|
found = {}
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
@@ -141,7 +168,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
|
|||||||
path = p.path or '/'
|
path = p.path or '/'
|
||||||
if p.query:
|
if p.query:
|
||||||
path += '?' + p.query
|
path += '?' + p.query
|
||||||
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
|
sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})}
|
||||||
|
login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop
|
||||||
|
if login:
|
||||||
|
sent['Authorization'] = login.header()
|
||||||
|
conn.request('GET', path, headers=sent)
|
||||||
live[0].settimeout(min(10, left()))
|
live[0].settimeout(min(10, left()))
|
||||||
response = conn.getresponse()
|
response = conn.getresponse()
|
||||||
if response.status in (301, 302, 303, 307, 308) and redirects:
|
if response.status in (301, 302, 303, 307, 308) and redirects:
|
||||||
|
|||||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user