mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-18 03:01:34 +02:00
Compare commits
160
Commits
v0.4.4
...
pc-in-headset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13f4fafab | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
ea0b6a80bd | ||
|
|
16b37bc667 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
fa9ee01cc1 | ||
|
|
63017e1969 | ||
|
|
b351fe7ad6 | ||
|
|
6aab62983e | ||
|
|
bcdd9d0830 | ||
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
671c4c06e7 | ||
|
|
443813a088 | ||
|
|
92a68285b5 | ||
|
|
5878c45ddb | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
d773babaa3 | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
c060df4b50 | ||
|
|
c7125bc3de | ||
|
|
9c33711e6f | ||
|
|
9d92396d49 | ||
|
|
be2eda8304 | ||
|
|
f5bed08931 | ||
|
|
e144325375 | ||
|
|
291b8ec295 | ||
|
|
9697812984 | ||
|
|
0fc6fbe596 | ||
|
|
f2261bac1c | ||
|
|
7c45d0ff24 | ||
|
|
19233fbf7d | ||
|
|
3ef978e232 | ||
|
|
8afa1113c9 | ||
|
|
7fe7e504da | ||
|
|
f678b4c8a2 | ||
|
|
33e4d2c092 | ||
|
|
621330d397 | ||
|
|
15e14c0d47 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
77c6c286cd | ||
|
|
ca53b26701 | ||
|
|
baec984449 | ||
|
|
18e5053b64 | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 | ||
|
|
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 | ||
|
|
3cb3defaa7 | ||
|
|
a992f6d896 | ||
|
|
d9d538690e | ||
|
|
391ecbeac5 | ||
|
|
1dc2a99617 | ||
|
|
d6f3d7c380 | ||
|
|
838eaa4e6b | ||
|
|
6104c3f192 | ||
|
|
da5c954ce4 | ||
|
|
2466c5aa14 | ||
|
|
060cf9cd57 | ||
|
|
cd202433bb | ||
|
|
c5fc552c60 | ||
|
|
559caa2dd0 |
No files matched your search
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
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:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- 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
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
run: scripts/e2e.sh
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PC host libraries
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "mac/frame-mac-view/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Native libraries
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-${{ matrix.os }}
|
||||
path: desktop/bundle
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py
|
||||
FRAME_PC_REQUIRE_NATIVE=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pc-host-windows
|
||||
path: desktop/bundle
|
||||
@@ -2,14 +2,22 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# native PC helpers, macOS helper, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -61,18 +69,62 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
script: dist:linux
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
- os: ubuntu-24.04-arm
|
||||
script: dist:linux:arm64
|
||||
files: app/dist/*.AppImage app/dist/*.deb
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Linux streaming libraries
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
arch=x64; if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python3 desktop/build.py --out "app/build/desktop/linux-$arch"
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-python
|
||||
mingw-w64-ucrt-x86_64-gstreamer
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-base
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-good
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-bad
|
||||
mingw-w64-ucrt-x86_64-gst-plugins-ugly
|
||||
- name: Windows streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Test the bundled Python with the native PC libraries
|
||||
if: runner.os != 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Windows ]; then
|
||||
platform=win; arch=x64
|
||||
python="$PWD/app/build/deps/win-x64/python/python.exe"
|
||||
native="$(cygpath -w "$PWD/app/build/desktop/win-x64")"
|
||||
else
|
||||
platform=linux; arch=x64
|
||||
if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
|
||||
python="$PWD/app/build/deps/linux-$arch/python/bin/python3"
|
||||
native="$PWD/app/build/desktop/linux-$arch"
|
||||
fi
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 "$python" -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
@@ -94,7 +146,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
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
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
+10
@@ -7,5 +7,15 @@ compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Shared desktop streaming controller build products
|
||||
desktop/controller.dll
|
||||
desktop/controller.dylib
|
||||
desktop/controller.so
|
||||
desktop/*.obj
|
||||
desktop/*.lib
|
||||
desktop/*.exp
|
||||
desktop/bundle/
|
||||
app/build/desktop/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
@@ -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
|
||||
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,
|
||||
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:
|
||||
@@ -213,16 +213,19 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Windows and Linux host implementation and test coverage |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | 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 |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [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 |
|
||||
|
||||
<details>
|
||||
|
||||
+46
-3
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -11,6 +11,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -115,10 +116,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -347,6 +349,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
|
||||
// The token goes from the sign-in window's own session straight to the server's
|
||||
// connect path; the page asks for it and only hears the server's answer.
|
||||
function postApi(apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error("Frame Control's server isn't running."));
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request(new URL(apiPath, url), {
|
||||
method: "POST", timeout: 60000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
|
||||
"Content-Length": Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => { text += c; });
|
||||
res.on("end", () => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(text); } catch {}
|
||||
resolve({ status: res.statusCode, data: parsed });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
let metaSignIn = null;
|
||||
const meta = () => (metaSignIn ||= createMetaSignIn({
|
||||
BrowserWindow, session, shell, parent: () => win,
|
||||
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
|
||||
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
|
||||
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
|
||||
}));
|
||||
ipcMain.handle("meta:signin", (e, acknowledged) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
|
||||
return meta().signIn();
|
||||
});
|
||||
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
|
||||
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
|
||||
// access token cookie (oc_ac_at) that page sets is handed to the local server's
|
||||
// existing connect path (format check, "who am I" with Meta, 0600 file).
|
||||
//
|
||||
// The window has its own persistent session (PARTITION), so the cookie is read only
|
||||
// from there: never from Frame Control's main window, never from any browser. It has
|
||||
// no preload and no Node, and it only navigates within Meta's login sites; anything
|
||||
// else opens in the system browser. Nothing here logs the token, and the page only
|
||||
// ever gets back the server's answer (connected, display name), never the token.
|
||||
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PARTITION = "persist:meta-signin";
|
||||
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
|
||||
const LOGIN_URL = "https://secure.oculus.com";
|
||||
const COOKIE = "oc_ac_at";
|
||||
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
|
||||
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
|
||||
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
|
||||
const TIMEOUT = 10 * 60 * 1000; // as BSManager
|
||||
|
||||
const underDomain = (host, domains) => {
|
||||
host = String(host || "").replace(/^\./, "").toLowerCase();
|
||||
return domains.some((d) => host === d || host.endsWith("." + d));
|
||||
};
|
||||
|
||||
// Plain HTTPS on the default port, to one of the login sites.
|
||||
function allowedUrl(raw) {
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
|
||||
return underDomain(u.hostname, ALLOWED);
|
||||
}
|
||||
|
||||
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
|
||||
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
|
||||
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
|
||||
function allowedSubframeUrl(raw) {
|
||||
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
return u.protocol === "https:" && !u.username && !u.password;
|
||||
}
|
||||
|
||||
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
|
||||
|
||||
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
|
||||
// check_token (ui/frame_meta.py) has the final word.
|
||||
function looksLikeUserToken(v) {
|
||||
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
|
||||
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
|
||||
}
|
||||
|
||||
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
|
||||
|
||||
// Anything token-shaped out of a message, in case a server error ever carried one.
|
||||
function scrub(text, token) {
|
||||
let s = String(text == null ? "" : text);
|
||||
if (token) s = s.split(token).join("<redacted>");
|
||||
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
|
||||
}
|
||||
|
||||
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
|
||||
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
|
||||
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
|
||||
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
|
||||
let active = null; // { win, promise, finish }
|
||||
const pending = new Set(); // cancels still to reach the server, from flows already finished
|
||||
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
|
||||
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
|
||||
|
||||
const openOutside = (target) => {
|
||||
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
|
||||
};
|
||||
|
||||
function signIn() {
|
||||
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
|
||||
if (active) { active.win.focus(); return active.promise; }
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
|
||||
ses.setPermissionCheckHandler(() => false);
|
||||
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
|
||||
if (ses.getUserAgent && ses.setUserAgent) {
|
||||
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
|
||||
}
|
||||
const p = parent();
|
||||
const win = new BrowserWindow({
|
||||
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
|
||||
...(p ? { parent: p } : {}),
|
||||
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
|
||||
});
|
||||
const wc = win.webContents;
|
||||
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
let done = false, busy = false, again = false, lastError = null, blocked = null;
|
||||
const tried = new Set(); // fingerprints of values already sent, so each goes once
|
||||
// The server refuses to save a connect from an attempt this window gave up on.
|
||||
const attemptId = crypto.randomUUID();
|
||||
|
||||
const onCookie = (_e, cookie, _cause, removed) => {
|
||||
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
|
||||
};
|
||||
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
|
||||
if (timer.unref) timer.unref();
|
||||
|
||||
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
|
||||
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
|
||||
// then has the server drop this attempt: only once the connect under way has settled,
|
||||
// so the server always sees the cancel after the connect (and deletes the token if
|
||||
// that connect saved it). The returned promise settles when that is done.
|
||||
let finished = null;
|
||||
let inflight = null; // the last connect sent
|
||||
function finish(result) {
|
||||
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
|
||||
done = true;
|
||||
ses.cookies.removeListener("changed", onCookie);
|
||||
clearTimeout(timer);
|
||||
if (active && active.win === win) active = null;
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
resolve(result);
|
||||
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
|
||||
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
|
||||
pending.add(finished);
|
||||
finished.then(() => pending.delete(finished));
|
||||
return finished;
|
||||
}
|
||||
|
||||
async function attempt() {
|
||||
if (done) return;
|
||||
if (busy) { again = true; return; }
|
||||
busy = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
// Only this window's own session, and only Meta's Oculus cookie.
|
||||
const cookies = await ses.cookies.get({ name: COOKIE });
|
||||
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
|
||||
if (!c || done) continue;
|
||||
const key = fingerprint(c.value);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
let r;
|
||||
try {
|
||||
inflight = connect(c.value, attemptId);
|
||||
r = await inflight;
|
||||
} catch {
|
||||
lastError = "Frame Control's local server didn't answer.";
|
||||
continue;
|
||||
}
|
||||
if (done) return;
|
||||
const d = (r && r.data) || {};
|
||||
if (r && r.status === 200 && d.connected) {
|
||||
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
|
||||
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
|
||||
return;
|
||||
}
|
||||
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
|
||||
} while (again && !done);
|
||||
} catch {
|
||||
// The session went away (the window closed); nothing to do.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
|
||||
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
|
||||
// Every frame: the window itself stays on the list; frames inside it may load any
|
||||
// HTTPS page (captchas), and one going anywhere else is just stopped.
|
||||
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
|
||||
// A stopped page change in the window itself says where it went (the host only), so
|
||||
// closing the stuck window explains why instead of "closed before you signed in".
|
||||
const stopped = (to) => {
|
||||
let host = "";
|
||||
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
|
||||
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
|
||||
};
|
||||
wc.on("will-frame-navigate", (e) => {
|
||||
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
|
||||
e.preventDefault();
|
||||
if (e.isMainFrame) stopped(e.url);
|
||||
});
|
||||
// Redirects fire for frames too: the same rule per frame (details on the event in
|
||||
// current Electron, positional arguments in older ones; unknown counts as the window).
|
||||
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
|
||||
const to = e && e.url !== undefined ? e.url : target;
|
||||
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
|
||||
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
|
||||
e.preventDefault();
|
||||
if (main) stopped(to);
|
||||
});
|
||||
// A page's beforeunload never keeps the window open.
|
||||
wc.on("will-prevent-unload", (e) => e.preventDefault());
|
||||
wc.setWindowOpenHandler(({ url: target }) => {
|
||||
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
|
||||
else openOutside(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
// Signed in: the cookie changes, or the window lands back on Meta's store site.
|
||||
ses.cookies.on("changed", onCookie);
|
||||
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
|
||||
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
|
||||
win.on("closed", () => finish({ ok: false, cancelled: true,
|
||||
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
|
||||
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
|
||||
: "The sign-in window was closed before you signed in." }));
|
||||
|
||||
active = { win, promise, finish };
|
||||
wc.loadURL(LOGIN_URL).catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
|
||||
// refuse to save it), destroys the window, then clears everything
|
||||
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
|
||||
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
|
||||
// It goes round until no window is open and no connect or cancel is left, so nothing
|
||||
// started before it can set a token after it (or after the Disconnect that follows).
|
||||
function signOut() {
|
||||
if (!signingOut) {
|
||||
signingOut = (async () => {
|
||||
for (;;) {
|
||||
if (active) {
|
||||
const { win, finish } = active;
|
||||
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
|
||||
// Clear only once nothing can write to the session again.
|
||||
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
|
||||
} else if (pending.size) {
|
||||
// A connect under way settles and is cancelled before the caller (Disconnect)
|
||||
// deletes the account, so it can't be saved afterwards.
|
||||
await Promise.all([...pending]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
await ses.clearStorageData();
|
||||
await ses.clearCache();
|
||||
if (ses.clearAuthCache) await ses.clearAuthCache();
|
||||
return true;
|
||||
})().finally(() => { signingOut = null; });
|
||||
}
|
||||
return signingOut;
|
||||
}
|
||||
|
||||
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
|
||||
}
|
||||
|
||||
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+26
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.7",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -11,8 +11,9 @@
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 && electron-builder --linux --x64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:linux:arm64": "node build/fetch-deps.js linux arm64 && electron-builder --linux --arm64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -39,6 +40,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -50,6 +52,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
@@ -182,7 +185,16 @@
|
||||
"entry": {
|
||||
"StartupWMClass": "frame-control"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deb": {
|
||||
"depends": [
|
||||
@@ -195,7 +207,16 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
+8
-1
@@ -6,7 +6,8 @@
|
||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
// And it passes update state both ways: see app/updater.js. It opens the Sign in
|
||||
// with Meta window and signs out of it (app/meta-signin.js).
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
@@ -39,6 +40,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
|
||||
signOut: () => ipcRenderer.invoke("meta:signout"),
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
// Run: node --test app/test/
|
||||
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
|
||||
const nodeTest = require("node:test");
|
||||
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
|
||||
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
|
||||
const assert = require("node:assert");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
|
||||
|
||||
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
|
||||
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
|
||||
|
||||
class FakeCookies extends EventEmitter {
|
||||
constructor() { super(); this.store = []; this.reads = 0; }
|
||||
async get(filter) {
|
||||
this.reads++;
|
||||
return this.store.filter((c) => !filter.name || c.name === filter.name);
|
||||
}
|
||||
set(cookie) { // as the page would: store it, then tell listeners
|
||||
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
|
||||
this.emit("changed", {}, cookie, "explicit", false);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession {
|
||||
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
|
||||
setPermissionRequestHandler(fn) { this.permission = fn; }
|
||||
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
|
||||
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
|
||||
setUserAgent(ua) { this.ua = ua; }
|
||||
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
|
||||
async clearCache() { this.cleared.push(["cache"]); }
|
||||
async clearAuthCache() { this.cleared.push(["auth"]); }
|
||||
}
|
||||
|
||||
function fakes({ timeout } = {}) {
|
||||
const log = []; // the order things happened in
|
||||
const sessions = new Map();
|
||||
const defaultSession = new FakeSession("default", log);
|
||||
const session = {
|
||||
defaultSession,
|
||||
fromPartition(name) {
|
||||
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
|
||||
return sessions.get(name);
|
||||
},
|
||||
};
|
||||
const windows = [];
|
||||
class BrowserWindow extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.destroyed = false;
|
||||
this.veto = false; // a page whose beforeunload keeps the window open on close()
|
||||
this.focused = 0;
|
||||
const wc = new EventEmitter();
|
||||
wc.loaded = [];
|
||||
wc.loadURL = async (u) => { wc.loaded.push(u); };
|
||||
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
|
||||
// Deliberately a different session: the code must read the partition's, not guess.
|
||||
wc.session = defaultSession;
|
||||
this.webContents = wc;
|
||||
windows.push(this);
|
||||
}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
focus() { this.focused++; }
|
||||
close() { if (this.destroyed || this.veto) return; this.destroy(); }
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
|
||||
}
|
||||
const opened = [];
|
||||
const shell = { openExternal: (u) => { opened.push(u); } };
|
||||
const calls = [], attempts = [], cancels = [];
|
||||
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
|
||||
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
|
||||
const cancel = async (attempt) => {
|
||||
cancels.push(attempt);
|
||||
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
|
||||
};
|
||||
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
|
||||
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
|
||||
meta: () => session.fromPartition(PARTITION) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
|
||||
|
||||
function navigate(wc, event, target) {
|
||||
let prevented = false;
|
||||
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
|
||||
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
|
||||
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
|
||||
"https://www.instagram.com/accounts/login/"]) {
|
||||
assert.ok(allowedUrl(ok), ok);
|
||||
}
|
||||
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
|
||||
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
|
||||
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
|
||||
"data:text/html,hi", "not a url"]) {
|
||||
assert.ok(!allowedUrl(bad), bad);
|
||||
}
|
||||
});
|
||||
|
||||
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const [w] = f.windows;
|
||||
const wp = w.opts.webPreferences;
|
||||
assert.strictEqual(wp.partition, PARTITION);
|
||||
assert.strictEqual(PARTITION, "persist:meta-signin");
|
||||
assert.strictEqual(wp.contextIsolation, true);
|
||||
assert.strictEqual(wp.nodeIntegration, false);
|
||||
assert.strictEqual(wp.sandbox, true);
|
||||
assert.strictEqual(wp.preload, undefined);
|
||||
assert.strictEqual(wp.devTools, false);
|
||||
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
|
||||
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
|
||||
// Permission prompts (camera, notifications...) are refused.
|
||||
let granted = null;
|
||||
f.meta().permission(null, "media", (v) => { granted = v; });
|
||||
assert.strictEqual(granted, false);
|
||||
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
|
||||
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
|
||||
w.close();
|
||||
});
|
||||
|
||||
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
|
||||
await settle();
|
||||
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
|
||||
f.windows[0].close();
|
||||
});
|
||||
|
||||
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
|
||||
const f = fakes();
|
||||
// The same cookie elsewhere: the default session and another partition. Never read.
|
||||
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
// A cookie with the right name on another site, and a signed-out value: ignored.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
|
||||
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
|
||||
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
|
||||
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
|
||||
});
|
||||
|
||||
test("landing on the logged-in page picks up a cookie set earlier", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.ok(!f.flow.isOpen());
|
||||
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
|
||||
assert.ok(!JSON.stringify(r).includes(TOKEN));
|
||||
// The cookie listener is gone: a later change sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
|
||||
: { status: 200, data: { connected: true, user: null } });
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.ok(!f.windows[0].isDestroyed());
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.user, null);
|
||||
});
|
||||
|
||||
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
|
||||
const seen = [];
|
||||
const names = ["log", "info", "warn", "error", "debug", "trace"];
|
||||
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
|
||||
const writes = [process.stdout.write, process.stderr.write];
|
||||
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
|
||||
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
|
||||
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
|
||||
let r;
|
||||
try {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // the user gives up
|
||||
r = await done;
|
||||
} finally {
|
||||
for (const n of names) console[n] = saved[n];
|
||||
[process.stdout.write, process.stderr.write] = writes;
|
||||
}
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.cancelled, true);
|
||||
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
|
||||
assert.match(r.error, /<redacted>/);
|
||||
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
|
||||
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
|
||||
});
|
||||
|
||||
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
|
||||
const f = fakes();
|
||||
const a = f.flow.signIn();
|
||||
const b = f.flow.signIn();
|
||||
assert.strictEqual(a, b);
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
assert.strictEqual(f.windows[0].focused, 1);
|
||||
f.windows[0].close();
|
||||
const r = await a;
|
||||
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
|
||||
const f = fakes();
|
||||
f.session.fromPartition("persist:other");
|
||||
const pending = f.flow.signIn();
|
||||
await f.flow.signOut();
|
||||
const r = await pending;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
|
||||
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
|
||||
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
|
||||
// The next sign-in starts with a new window.
|
||||
f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.windows[1].close();
|
||||
});
|
||||
|
||||
test("the first look at a cookie value matches the server's token rules", () => {
|
||||
assert.ok(looksLikeUserToken(TOKEN));
|
||||
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
|
||||
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
|
||||
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
|
||||
assert.ok(!looksLikeUserToken(bad), String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
function frameNav(wc, url, isMainFrame) {
|
||||
let prevented = false;
|
||||
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
|
||||
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
|
||||
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
|
||||
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
|
||||
assert.strictEqual(frameNav(wc, ok, false), false, ok);
|
||||
}
|
||||
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
|
||||
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
|
||||
"chrome://settings", "not a url"]) {
|
||||
assert.strictEqual(frameNav(wc, bad, false), true, bad);
|
||||
}
|
||||
// The window itself: Meta's sites only.
|
||||
assert.strictEqual(frameNav(wc, "about:blank", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
|
||||
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
function redirect(wc, url, isMainFrame, style) {
|
||||
let prevented = false;
|
||||
const e = { preventDefault: () => { prevented = true; } };
|
||||
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
|
||||
else wc.emit("will-redirect", e, url, false, isMainFrame);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("redirects follow the same rule per frame", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
for (const style of ["details", "positional"]) {
|
||||
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
|
||||
// The window: Meta's sites only.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
|
||||
}
|
||||
// Without frame information it is treated as the window.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
|
||||
assert.deepStrictEqual(f.opened, []);
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
test("a redirect the window stops is named when the window is closed", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
|
||||
f.windows[0].close();
|
||||
const r = await p;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
|
||||
assert.doesNotMatch(r.error, /SECRET|code=/);
|
||||
});
|
||||
|
||||
test("a stopped redirect is forgotten once the window loads a page again", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
|
||||
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
|
||||
f.windows[0].close();
|
||||
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
});
|
||||
|
||||
test("a page's beforeunload can't keep the window open", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
let prevented = false;
|
||||
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
|
||||
assert.ok(prevented);
|
||||
// Even if close() would be vetoed, finishing destroys the window.
|
||||
w.veto = true;
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.ok(w.isDestroyed());
|
||||
});
|
||||
|
||||
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
|
||||
for (const outcome of ["saved", "refused", "failed"]) {
|
||||
const f = fakes();
|
||||
let release, fail;
|
||||
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
f.windows[0].close(); // the user gives up while the server and Meta are answering
|
||||
const r = await done; // the page hears at once
|
||||
assert.strictEqual(r.ok, false);
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
|
||||
if (outcome === "saved") release({ status: 200, data: { connected: true } });
|
||||
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
|
||||
else fail(new Error("socket hang up"));
|
||||
await settle();
|
||||
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
|
||||
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
|
||||
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
}
|
||||
});
|
||||
|
||||
test("closing before any connect needs no cancel", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await f.flow.signOut();
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
});
|
||||
|
||||
test("a timeout during a connect cancels after it settles", async () => {
|
||||
const f = fakes({ timeout: 50 });
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.match(r.error, /timed out/);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.log.length = 0;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false); // still waiting for the connect
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 200, data: { connected: true } }); // it saved, just now
|
||||
await out;
|
||||
// Only now would the page post its disconnect: the cancel (which deletes what that
|
||||
// connect saved) has reached the server, and the session is cleared.
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("a later disconnect still waits for a closed window's connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await done;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; });
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
|
||||
const f = fakes();
|
||||
// Nothing sent: closing cancels nothing.
|
||||
const a = f.flow.signIn();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[1].veto = true; // and its page tries to stay open
|
||||
f.log.length = 0;
|
||||
await f.flow.signOut();
|
||||
assert.strictEqual((await b).ok, false);
|
||||
assert.ok(f.windows[1].isDestroyed());
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
// After sign-out a new cookie in the partition sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("each sign-in has its own attempt id", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
f.windows[1].close();
|
||||
await b;
|
||||
await settle();
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
|
||||
assert.deepStrictEqual(f.cancels, f.attempts);
|
||||
});
|
||||
|
||||
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const first = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
|
||||
assert.strictEqual((await first).ok, false);
|
||||
assert.ok(f.flow.isSigningOut());
|
||||
// The page answered, so the user tries again at once: refused, no window, nothing sent.
|
||||
const second = await f.flow.signIn();
|
||||
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
// A second sign-out joins the first.
|
||||
assert.strictEqual(f.flow.signOut(), out);
|
||||
release({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
|
||||
assert.ok(!f.flow.isSigningOut());
|
||||
// Once it is done, signing in works again.
|
||||
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
|
||||
const third = f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
assert.strictEqual((await third).ok, true);
|
||||
});
|
||||
|
||||
test("sign-out goes round until no window and no connect or cancel is left", async () => {
|
||||
const f = fakes();
|
||||
let releaseA;
|
||||
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // window A closed; its cancel waits on its connect
|
||||
await a;
|
||||
// Window B is open, with its own connect under way, when sign-out starts.
|
||||
let releaseB;
|
||||
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
let finished = false;
|
||||
const out = f.flow.signOut().then(() => { finished = true; });
|
||||
await b;
|
||||
releaseB({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.strictEqual(finished, false); // A's connect is still under way
|
||||
releaseA({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.strictEqual(f.log[f.log.length - 1], "clear");
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-hairpin",
|
||||
"date": "2026-09-28T22:36:19",
|
||||
"commit": "c5fc552+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 351,
|
||||
"frames_drawn": 351,
|
||||
"frames_shown": 324,
|
||||
"duration_s": 10.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 351
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 351
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.9,
|
||||
"p99": 3.5,
|
||||
"n": 351
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.4,
|
||||
"p99": 0.6,
|
||||
"n": 351
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.6,
|
||||
"p95": 75.7,
|
||||
"p99": 130.1,
|
||||
"n": 351
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.1,
|
||||
"p99": 4.1,
|
||||
"n": 351
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.1,
|
||||
"n": 351
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 18.1,
|
||||
"p99": 24.9,
|
||||
"n": 324
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 80.3,
|
||||
"p99": 134.2,
|
||||
"n": 351
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.0,
|
||||
"p95": 71.8,
|
||||
"p99": 146.3,
|
||||
"n": 324
|
||||
}
|
||||
},
|
||||
"fps": 34.8,
|
||||
"fps_shown": 32.0,
|
||||
"late_pct": 53.41,
|
||||
"stall_max": 226.8,
|
||||
"stalls_over_100ms": 7,
|
||||
"mbps": 0.26,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 607,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.3,
|
||||
"p95": 98.4,
|
||||
"p99": 110.1,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 54.7,
|
||||
"p95": 98.9,
|
||||
"p99": 114.0,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 17.1,
|
||||
"p95": 48.3,
|
||||
"p99": 53.1,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 12.5,
|
||||
"p95": 32.9,
|
||||
"p99": 35.7,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 16.8,
|
||||
"p95": 50.0,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 18,
|
||||
"sent": 18,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 38,
|
||||
"mbps": 0.3,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 33,
|
||||
"mbps": 0.24,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 103.5,
|
||||
"bitrate": 3845448,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 39,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 29.9,
|
||||
"bitrate": 5283790,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 25,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 17.8,
|
||||
"content_p95": 175.9,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 34,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 69.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 17.7,
|
||||
"content_p95": 68.7,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 38,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 18.2,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 30,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 111.0,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 35,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 13.4,
|
||||
"content_p95": 102.6,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 12.3,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 3,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 20.9,
|
||||
"content_p95": 24.7,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 80.3,
|
||||
"input_p50": 43.3,
|
||||
"input_p95": 98.4,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"target": 3442081,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 13.334,
|
||||
"inFlight": 1,
|
||||
"slack": 71.738,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 3.77,
|
||||
"e": "target 2764800 bit/s; tier 0"
|
||||
},
|
||||
{
|
||||
"t": 7.42,
|
||||
"e": "target 1725204 bit/s; tier 0"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
|
||||
"show_s": 1.88,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-final",
|
||||
"date": "2026-09-28T22:55:27",
|
||||
"commit": "cd20243",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "native",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 400,
|
||||
"frames_drawn": 400,
|
||||
"frames_shown": 339,
|
||||
"duration_s": 10.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 400
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.7,
|
||||
"p99": 3.1,
|
||||
"n": 400
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.8,
|
||||
"p95": 25.2,
|
||||
"p99": 37.8,
|
||||
"n": 400
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.5,
|
||||
"p99": 2.0,
|
||||
"n": 400
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.7,
|
||||
"p99": 1.0,
|
||||
"n": 400
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 19.3,
|
||||
"p99": 26.8,
|
||||
"n": 339
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 28.6,
|
||||
"p99": 41.3,
|
||||
"n": 400
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.6,
|
||||
"p95": 39.0,
|
||||
"p99": 47.2,
|
||||
"n": 339
|
||||
}
|
||||
},
|
||||
"fps": 39.3,
|
||||
"fps_shown": 33.4,
|
||||
"late_pct": 50.25,
|
||||
"stall_max": 69.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.35,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 612,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 76.6,
|
||||
"p95": 98.2,
|
||||
"p99": 127.8,
|
||||
"n": 19
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 80.5,
|
||||
"p95": 107.8,
|
||||
"p99": 128.1,
|
||||
"n": 16
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 36.5,
|
||||
"p95": 57.6,
|
||||
"p99": 83.2,
|
||||
"n": 19
|
||||
},
|
||||
"mac": {
|
||||
"p50": 14.6,
|
||||
"p95": 31.3,
|
||||
"p99": 31.7,
|
||||
"n": 19
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.1,
|
||||
"p95": 32.8,
|
||||
"p99": 36.2,
|
||||
"n": 19
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 19,
|
||||
"sent": 19,
|
||||
"seen": 19
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 40,
|
||||
"mbps": 0.34,
|
||||
"content_p50": 11.7,
|
||||
"content_p95": 27.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 11.9,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 41,
|
||||
"mbps": 0.35,
|
||||
"content_p50": 11.4,
|
||||
"content_p95": 16.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 12.7,
|
||||
"content_p95": 28.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 38,
|
||||
"mbps": 0.33,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 41,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 29.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 41,
|
||||
"mbps": 0.31,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 38,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 25.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 6,
|
||||
"mbps": 0.03,
|
||||
"content_p50": 17.6,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 28.6,
|
||||
"input_p50": 76.6,
|
||||
"input_p95": 98.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "acceptable",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"target": 5529600,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 12.332,
|
||||
"inFlight": 0,
|
||||
"slack": 74.12,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
|
||||
"show_s": 1.75,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
|
||||
out = root / ('controller' + suffix)
|
||||
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
|
||||
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
|
||||
else:
|
||||
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
print(out)
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build our native PC adapter and bundle its ordinary shared libraries.
|
||||
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
if not win:
|
||||
modules += ['gio-unix-2.0']
|
||||
import shlex
|
||||
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
|
||||
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
|
||||
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
|
||||
if not win:
|
||||
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
|
||||
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
|
||||
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
|
||||
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad', 'x264']
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
|
||||
if not matches:
|
||||
raise SystemExit('Missing GStreamer library: ' + name)
|
||||
for source in matches:
|
||||
target = plugins_out / source.name
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
if not win:
|
||||
# PipeWire dynamically loads support and protocol modules. ldd cannot
|
||||
# discover those; bundle them with a private client-only config instead
|
||||
# of accidentally loading a different distro's client modules/config.
|
||||
libdir = plugin_dir.parent
|
||||
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
|
||||
'pipewire-0.3/libpipewire-module-client-node.so',
|
||||
'pipewire-0.3/libpipewire-module-adapter.so',
|
||||
'spa-0.2/support/libspa-support.so',
|
||||
'spa-0.2/support/libspa-dbus.so',
|
||||
'spa-0.2/videoconvert/libspa-videoconvert.so'):
|
||||
source = libdir / relative
|
||||
if not source.is_file():
|
||||
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
|
||||
target = destination / 'lib' / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
config = destination / 'share' / 'pipewire'
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / 'client.conf').write_text(
|
||||
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
|
||||
'context.modules = [\n'
|
||||
' { name = libpipewire-module-protocol-native }\n'
|
||||
' { name = libpipewire-module-client-node }\n'
|
||||
' { name = libpipewire-module-adapter }\n'
|
||||
']\n')
|
||||
copied = set()
|
||||
while pending:
|
||||
binary = pending.pop()
|
||||
if win:
|
||||
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
|
||||
dependencies = [prefix / 'bin' / name for name in imported]
|
||||
else:
|
||||
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
|
||||
for dep in dependencies:
|
||||
if not dep.is_file() or dep.name in copied:
|
||||
continue
|
||||
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
|
||||
continue
|
||||
shutil.copy2(dep, libs_out / dep.name)
|
||||
copied.add(dep.name)
|
||||
pending.append(dep)
|
||||
# License texts and package provenance accompany the dynamically linked
|
||||
# libraries. Distribution builders keep the upstream package/source URLs.
|
||||
licenses = destination / 'licenses'
|
||||
licenses.mkdir(exist_ok=True)
|
||||
if win:
|
||||
source = prefix / 'share' / 'licenses'
|
||||
if source.exists():
|
||||
shutil.copytree(source, licenses, dirs_exist_ok=True)
|
||||
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
|
||||
else:
|
||||
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
|
||||
# Debian copyright files contain licenses and source homepage details.
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
build(parser.parse_args().out.resolve())
|
||||
@@ -0,0 +1,125 @@
|
||||
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
|
||||
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
|
||||
#include "controller.h"
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video-event.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
|
||||
typedef struct {
|
||||
GstElement *pipeline, *encoder, *sink, *raw_queue;
|
||||
Gate gate;
|
||||
GstSample *sample;
|
||||
GstMapInfo map;
|
||||
int mapped;
|
||||
char error[512];
|
||||
} Capture;
|
||||
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
|
||||
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
|
||||
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
|
||||
FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
int64_t now=fc_now(), cap=now;
|
||||
GstClock *clock=gst_element_get_clock(c->pipeline);
|
||||
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
|
||||
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
|
||||
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
|
||||
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
|
||||
}
|
||||
if(clock)gst_object_unref(clock);
|
||||
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
|
||||
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
int phase=0;
|
||||
for(;;) {
|
||||
/* While congested, prefer a newer raw picture queued upstream. If
|
||||
* nothing changed, retain this last picture until the gate opens;
|
||||
* an idle window must not stay stale after a dropped final update. */
|
||||
guint queued=0;
|
||||
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
|
||||
if(queued)return GST_PAD_PROBE_DROP;
|
||||
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
|
||||
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
phase=2;g_usleep(2000);
|
||||
}
|
||||
}
|
||||
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
|
||||
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
|
||||
c->pipeline=gst_parse_launch(pipeline,&e);
|
||||
if(e || !c->pipeline) {
|
||||
g_strlcpy(error,e?e->message:"No pipeline",capacity);
|
||||
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
|
||||
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
|
||||
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
|
||||
if(!c->encoder || !c->sink || !raw) {
|
||||
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
|
||||
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
|
||||
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
|
||||
GstPad *p=gst_element_get_static_pad(raw,"src");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
|
||||
p=gst_element_get_static_pad(c->encoder,"sink");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
|
||||
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
|
||||
return c;
|
||||
}
|
||||
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
|
||||
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
|
||||
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
|
||||
GstBus *bus=gst_element_get_bus(c->pipeline);
|
||||
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
|
||||
if(m) {
|
||||
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
|
||||
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
|
||||
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
|
||||
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
|
||||
gst_message_unref(m);return -1;
|
||||
}
|
||||
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
|
||||
if(!c->sample) {
|
||||
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
|
||||
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
GstBuffer *b=gst_sample_get_buffer(c->sample);
|
||||
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
|
||||
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
|
||||
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
|
||||
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
|
||||
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
|
||||
}
|
||||
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
|
||||
FC_API void fc_capture_bitrate(Capture *c,int bps) {
|
||||
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
|
||||
}
|
||||
FC_API void fc_capture_test(Capture *c,uint32_t input) {
|
||||
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
|
||||
if(source) {
|
||||
/* Each benchmark click visibly changes the ball, before injection is
|
||||
* timestamped. This is a response, not merely a protocol input echo. */
|
||||
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
|
||||
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
|
||||
}
|
||||
}
|
||||
FC_API void fc_capture_key(Capture *c) {
|
||||
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
|
||||
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
|
||||
}
|
||||
FC_API void fc_capture_close(Capture *c) {
|
||||
gst_element_set_state(c->pipeline,GST_STATE_NULL);
|
||||
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
|
||||
if(c->sample)gst_sample_unref(c->sample);
|
||||
if(c->raw_queue)gst_object_unref(c->raw_queue);
|
||||
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Extracted from the Mac agent's RateController. Same gate, demand protection,
|
||||
* bitrate recovery and tier hysteresis for every host. Bounded sample buffers. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CAP 1024
|
||||
#define MIN(a,b) ((a)<(b)?(a):(b))
|
||||
#define MAX(a,b) ((a)>(b)?(a):(b))
|
||||
typedef struct { int64_t t, v; uint32_t seq; } Sample;
|
||||
typedef struct { Sample a[CAP]; int n; } Samples;
|
||||
struct FCController {
|
||||
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
|
||||
int64_t decrease, increase, below, above, slack;
|
||||
Samples flight, rtts, acked, sent, captures;
|
||||
};
|
||||
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
|
||||
static const double floors[] = {.45,.28,.16,.08,0};
|
||||
static void remove_first(Samples *s, int n) {
|
||||
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
|
||||
}
|
||||
static void add(Samples *s, Sample v, int cap) {
|
||||
if (s->n >= cap) remove_first(s, 1);
|
||||
s->a[s->n++] = v;
|
||||
}
|
||||
static void expire(Samples *s, int64_t oldest) {
|
||||
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
|
||||
remove_first(s,n);
|
||||
}
|
||||
static int64_t base(FCController *c) {
|
||||
int64_t v=0;
|
||||
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
|
||||
return v;
|
||||
}
|
||||
static int compare(const void *a, const void *b) {
|
||||
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
|
||||
int64_t a[CAP]; int n=0;
|
||||
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
|
||||
if(!n) return 0;
|
||||
qsort(a,(size_t)n,sizeof(int64_t),compare);
|
||||
return a[n*numerator/denominator];
|
||||
}
|
||||
static int rate(Samples *s) {
|
||||
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
|
||||
return (int)MIN(total*16,2147483647);
|
||||
}
|
||||
FCController *fc_new(int max_fps,int enabled) {
|
||||
FCController *c=calloc(1,sizeof(*c));
|
||||
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
|
||||
return c;
|
||||
}
|
||||
void fc_free(FCController *c) {free(c);}
|
||||
void fc_ceiling(FCController *c,int bps) {
|
||||
if(!c->target || c->target>bps) c->target=bps;
|
||||
c->ceiling=bps;
|
||||
}
|
||||
int fc_gate(FCController *c,int64_t now,int counts) {
|
||||
if(!c->enabled || !c->saw_ack) return 1;
|
||||
expire(&c->flight,now-2000000);
|
||||
if(!c->flight.n) return 1;
|
||||
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
|
||||
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
|
||||
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
|
||||
if(counts) c->held++;
|
||||
return 0;
|
||||
}
|
||||
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
|
||||
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
|
||||
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
|
||||
}
|
||||
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
|
||||
c->saw_ack=1;
|
||||
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
|
||||
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
|
||||
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
|
||||
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int fc_update(FCController *c,int64_t now) {
|
||||
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
|
||||
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
|
||||
expire(&c->captures,now-1000000);
|
||||
if(!c->enabled || c->ceiling<=0) return 0;
|
||||
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
|
||||
int64_t jitter=spread ? spread-baseline : 0;
|
||||
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
|
||||
int64_t recent=quantile(&c->rtts,now-300000,1,2);
|
||||
int64_t queue=recent ? recent-baseline : 0;
|
||||
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
|
||||
int delivered=rate(&c->acked),sending=rate(&c->sent);
|
||||
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
|
||||
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
|
||||
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
|
||||
int congested=signal && c->signal;c->signal=signal;c->held=0;
|
||||
if(congested && now-c->decrease>300000) {
|
||||
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
|
||||
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
|
||||
c->target=next;c->decrease=now;
|
||||
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
|
||||
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
|
||||
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
|
||||
}
|
||||
double share=(double)c->target/MAX(c->ceiling,1);
|
||||
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
|
||||
if(!c->below)c->below=now;
|
||||
if(now-c->below>500000) {
|
||||
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
|
||||
c->below=0;
|
||||
}
|
||||
} else c->below=0;
|
||||
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
|
||||
if(!c->above)c->above=now;
|
||||
if(now-c->above>2000000) {c->tier--;c->above=0;}
|
||||
} else c->above=0;
|
||||
return c->target;
|
||||
}
|
||||
int64_t fc_value(FCController *c,int field) {
|
||||
switch(field) {
|
||||
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
|
||||
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
|
||||
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shared latency controller. Times are monotonic host microseconds.
|
||||
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
|
||||
* encoded inter frame would break the decoder's reference chain. */
|
||||
#ifndef FRAME_CONTROLLER_H
|
||||
#define FRAME_CONTROLLER_H
|
||||
#include <stdint.h>
|
||||
#ifdef _WIN32
|
||||
#define FC_API __declspec(dllexport)
|
||||
#else
|
||||
#define FC_API
|
||||
#endif
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct FCController FCController;
|
||||
FC_API FCController *fc_new(int fps, int enabled);
|
||||
FC_API void fc_free(FCController *c);
|
||||
FC_API void fc_ceiling(FCController *c, int bps);
|
||||
FC_API int fc_gate(FCController *c, int64_t now, int counts);
|
||||
FC_API void fc_capture(FCController *c, int64_t now);
|
||||
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
|
||||
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
|
||||
FC_API int fc_update(FCController *c, int64_t now);
|
||||
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
|
||||
FC_API int64_t fc_value(FCController *c, int field);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
|
||||
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
|
||||
* Input uses only the devices granted in Start's response. */
|
||||
#ifndef _WIN32
|
||||
#include "controller.h"
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#define BUS "org.freedesktop.portal.Desktop"
|
||||
#define PATH "/org/freedesktop/portal/desktop"
|
||||
#define RD "org.freedesktop.portal.RemoteDesktop"
|
||||
#define SC "org.freedesktop.portal.ScreenCast"
|
||||
typedef struct {
|
||||
GDBusConnection *bus;
|
||||
GMainContext *context;
|
||||
char *session;
|
||||
int fd;
|
||||
unsigned node, devices;
|
||||
int width,height;
|
||||
} Portal;
|
||||
typedef struct {GVariant *result;int done;unsigned code;} Response;
|
||||
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
|
||||
const char *signal,GVariant *params,gpointer data) {
|
||||
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
|
||||
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
|
||||
}
|
||||
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
|
||||
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
|
||||
const char *token,char *error,int capacity) {
|
||||
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
|
||||
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
|
||||
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
|
||||
Response r={0};GError *e=NULL;
|
||||
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
|
||||
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
|
||||
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
|
||||
if(reply)g_variant_unref(reply);
|
||||
int64_t deadline=g_get_monotonic_time()+120000000;
|
||||
while(!e && !r.done && g_get_monotonic_time()<deadline) {
|
||||
while(g_main_context_iteration(p->context,FALSE)) {}
|
||||
g_usleep(10000);
|
||||
}
|
||||
if(!r.done) {
|
||||
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(closed)g_variant_unref(closed);
|
||||
}
|
||||
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
|
||||
if(e || !r.done || r.code) {
|
||||
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
|
||||
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
|
||||
}
|
||||
return r.result;
|
||||
}
|
||||
FC_API void fc_portal_close(Portal *p) {
|
||||
if(!p)return;
|
||||
if(p->session && p->bus) {
|
||||
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(r)g_variant_unref(r);
|
||||
}
|
||||
if(p->fd>=0){close(p->fd);p->fd=-1;}
|
||||
g_free(p->session);if(p->bus)g_object_unref(p->bus);
|
||||
if(p->context)g_main_context_unref(p->context);g_free(p);
|
||||
}
|
||||
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
|
||||
* previously consumed protocol socket is not a new connection.
|
||||
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
|
||||
* pipewiresrc never takes the fd it is given: its core connects with
|
||||
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
|
||||
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
|
||||
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
|
||||
* close; not closing it would leak one socket per pipeline reopen. The
|
||||
* caller closes the previous pipeline before asking for a new fd. */
|
||||
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
|
||||
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
GUnixFDList *fds=NULL;GError *e=NULL;
|
||||
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
|
||||
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
|
||||
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
|
||||
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
|
||||
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
if(p->fd>=0)close(p->fd);
|
||||
p->fd=fd;return fd;
|
||||
}
|
||||
FC_API Portal *fc_portal_select(char *error,int capacity) {
|
||||
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
|
||||
g_main_context_push_thread_default(p->context);
|
||||
GError *e=NULL;GVariant *r=NULL;
|
||||
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
|
||||
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
|
||||
GVariantBuilder b;char token[64],session[64];
|
||||
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
|
||||
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
|
||||
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
|
||||
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
|
||||
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));
|
||||
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"devices","u",&p->devices);
|
||||
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
|
||||
if(streams && g_variant_n_children(streams)>0) {
|
||||
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
|
||||
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
|
||||
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
|
||||
g_variant_unref(props);g_variant_unref(entry);
|
||||
}
|
||||
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
|
||||
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
|
||||
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
|
||||
g_main_context_pop_thread_default(p->context);return p;
|
||||
fail:
|
||||
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
|
||||
}
|
||||
FC_API int fc_portal_value(Portal *p,int field) {
|
||||
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
|
||||
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
|
||||
}
|
||||
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
|
||||
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
if(type==0 && (p->devices&2)) {
|
||||
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
|
||||
} else if(type==1 && (p->devices&2)) {
|
||||
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else if(type==2 && (p->devices&2)) {
|
||||
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
|
||||
} else if(type==3 && (p->devices&1)) {
|
||||
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else return 0;
|
||||
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
|
||||
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
|
||||
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
|
||||
}
|
||||
#endif
|
||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
|
||||
+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
|
||||
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`:
|
||||
`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
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
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
|
||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||
|
||||
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
|
||||
quota. Tokens are sent only to `api.github.com`, never written to the cache,
|
||||
never sent to asset hosts, and removed on redirects. The adapter does not
|
||||
read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
For a higher API quota, add a GitHub access token in the store's source
|
||||
settings (the GitHub row's token field) or through the server API
|
||||
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
|
||||
`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
|
||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||
|
||||
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Split APKs (a base plus `split_*.apk` files) install as one app:
|
||||
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
|
||||
APK, so splits that add native libraries or assets are merged into the base
|
||||
and re-signed; density and language splits are refused. See
|
||||
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
|
||||
+14
-3
@@ -3,8 +3,9 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
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:
|
||||
|
||||
@@ -57,7 +58,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
|
||||
@@ -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
|
||||
|
||||
**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
|
||||
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
|
||||
|
||||
+71
-9
@@ -17,14 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
The window has six tabs: **Home** (what the headset's doing and what you'd do
|
||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
||||
power), **Devices** (your headsets and their addresses), and **Control** (the
|
||||
searchable Control Center, metrics, supported settings and headset guidance).
|
||||
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
|
||||
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
|
||||
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.
|
||||
@@ -96,6 +103,61 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
@@ -183,5 +245,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
||||
require a separate user approval; power also retains its password prompt. The
|
||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
||||
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` |
|
||||
| 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) |
|
||||
| **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) |
|
||||
| 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 |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+2
-2
@@ -114,8 +114,8 @@ don't.
|
||||
|
||||
## Family and comfort
|
||||
|
||||
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
||||
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;
|
||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||
runs. See [the behavior and verification limits](family-comfort.md).
|
||||
@@ -0,0 +1,371 @@
|
||||
# My Meta library (Experimental)
|
||||
|
||||
Install Quest games your Meta account owns straight from Meta, test whether
|
||||
they run on the Steam Frame, and add the result to the shared compatibility
|
||||
reports. It is off until you connect, and it lives in **Android → My Meta
|
||||
library**.
|
||||
|
||||
**Read this first.** My Meta library uses Meta's undocumented internal API
|
||||
with your own Meta access token, as the open-source tools QAVS, DownQuest and
|
||||
BSManager do. It may stop working whenever Meta changes things, and using it
|
||||
may be against Meta's terms. Your account is your responsibility. Frame
|
||||
Control is unofficial and not made or endorsed by Meta or Valve.
|
||||
|
||||
What it will and won't do:
|
||||
|
||||
- It lists and downloads only games your account owns (its active
|
||||
entitlements). It never downloads anything else.
|
||||
- It installs games as Meta ships them. There is **no licence-check bypass**:
|
||||
no patching, no stub libraries, no OVRPort or FramePort. A game that
|
||||
enforces Meta's licence check may quit or hang on the Frame, because that
|
||||
check needs Meta's services, which the Frame doesn't have.
|
||||
- Game files go only to a cache folder on your computer and then to your
|
||||
Frame. They are deleted from the cache after each install, and never
|
||||
uploaded or shared. Reports carry results only (below), never files or your
|
||||
token.
|
||||
|
||||
## Your token
|
||||
|
||||
Frame Control never reads your browser's cookies. There are two ways to give
|
||||
it your token.
|
||||
|
||||
**Sign in with Meta** (desktop app only; a plain browser doesn't show it).
|
||||
Tick **I understand…**, then choose **Sign in with Meta…**. Meta's own login
|
||||
page (`https://secure.oculus.com`, the address BSManager's "Log in with Meta"
|
||||
opens) appears in a Frame Control window. Sign in there as usual. When Meta's
|
||||
site sets its access token cookie (`oc_ac_at`), Frame Control takes it from
|
||||
that window, connects with it exactly as if you had pasted it (below), and
|
||||
closes the window. The window:
|
||||
|
||||
- has its own storage (the Electron session `persist:meta-signin`), separate
|
||||
from Frame Control's main window and from every browser on your computer;
|
||||
the cookie is read only from there, and only for `oculus.com`;
|
||||
- runs Meta's page sandboxed, with no access to Frame Control (no preload, no
|
||||
Node, context isolation on, no developer tools) and every permission request
|
||||
or check (camera, notifications…) refused;
|
||||
- only goes to `https://` pages on `oculus.com`, `meta.com`, `facebook.com` and
|
||||
`instagram.com` (and their subdomains; Meta's login passes through Instagram).
|
||||
A link anywhere else opens in your system browser; a redirect anywhere else
|
||||
is stopped, and closing the window then says where it went. Pop-ups on those sites open in the same
|
||||
window. Frames inside the page may load any `https://` page (Meta's login
|
||||
can embed captcha and CDN frames from other sites, such as Arkose Labs,
|
||||
hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`,
|
||||
`about:srcdoc`); a frame going to any other scheme (`http:`, `data:`,
|
||||
`blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same
|
||||
rule for the frame they happen in. Frames can't reach the
|
||||
token: the cookie is read by Frame Control's main process, and the window
|
||||
has no IPC;
|
||||
- sends a cookie value on only if it looks like a user token (`OC` or `FRL`,
|
||||
mixed case, no `%`, `|` or `:`), and each value once. If the server refuses
|
||||
it, the window stays open; close it to see why;
|
||||
- gives up after 10 minutes, as BSManager does;
|
||||
- when it is closed, times out or is signed out of, stops reading cookies
|
||||
first, then is destroyed (a page's "leave this page?" can't keep it open),
|
||||
and, if it had sent a token, tells the server to drop that sign-in: each
|
||||
sign-in has a random attempt id. The cancel is sent only once the connect
|
||||
under way has finished (saved, refused or failed), so the server always
|
||||
gets it after the connect; with nothing sent, there is nothing to cancel.
|
||||
The server lists an attempt only while its
|
||||
connect is being checked: cancelled then, it isn't saved; cancelled just
|
||||
after its token was saved (even before the connect has finished tidying
|
||||
up), that token is deleted (as a disconnect). A cancel
|
||||
never removes an account a later sign-in or a pasted token saved, and one
|
||||
for an unknown attempt changes nothing.
|
||||
|
||||
The page never sees the token: the desktop app hands it straight to the local
|
||||
server's connect path and tells the page only the answer (connected, and your
|
||||
Meta display name). It isn't logged anywhere.
|
||||
|
||||
The window keeps you signed in to Meta (in `persist:meta-signin` only) until
|
||||
you **Disconnect**, which also signs out of Meta in Frame Control: it clears
|
||||
that window's cookies, storage and cache, so the next sign-in starts fresh.
|
||||
**Sign out of Meta in Frame Control** next to the sign-in button does just
|
||||
that part, for a sign-in that didn't connect.
|
||||
|
||||
**Paste a token.** You copy your own token and paste it in. The two ways
|
||||
BSManager's guide describes
|
||||
([How to obtain your Oculus Token](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token)):
|
||||
|
||||
1. **From the Meta website.** Sign in at `https://secure.oculus.com`. Open the
|
||||
browser's developer tools, then **Application** (Chrome, Edge) or
|
||||
**Storage** (Firefox) → **Cookies** → `https://secure.oculus.com`. Copy the
|
||||
value of `oc_ac_at`. It starts with `OC`.
|
||||
2. **From the Meta Quest PC app** (Windows). Press **Ctrl+Shift+I**, open
|
||||
**Network**, press **Ctrl+R**, type `graph` in the filter, open the first
|
||||
request, and copy the access token from its **Payload**. It starts with
|
||||
`FRL`.
|
||||
|
||||
Treat it like a password: anyone with it can act as your Meta account. Signing
|
||||
out of the Meta website or app usually ends it; then connect again with a new
|
||||
one.
|
||||
|
||||
Frame Control refuses values that can't be a user token before sending them
|
||||
anywhere: anything not starting with `OC` or `FRL`, URL-encoded copies (with
|
||||
`%`), app tokens (with `|` or `:`), cut-off values (under 20 characters), and
|
||||
anything with characters other than letters, digits and `+ / = - _` (the
|
||||
base64 and URL-safe base64 sets), or with no lower-case letter (real tokens are
|
||||
mixed case). The public tools only describe tokens made
|
||||
of letters and digits; if a real token needs another character, it has to be
|
||||
added in one place (`TOKEN_CHARS` in `ui/frame_meta.py`), which also makes
|
||||
the redaction look for it.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Tick **I understand…**, then either choose **Sign in with Meta…** (desktop
|
||||
app) or paste your token and choose **Connect**. Frame Control asks Meta
|
||||
"who am I" with it (a read-only query); only a token Meta accepts is kept.
|
||||
2. Your Quest games are listed with their version, size, game data, and the
|
||||
community verdict from the compatibility database when there is one.
|
||||
3. **Install** downloads the game and installs it on the Frame, as [Import
|
||||
from my Quest](quest-import.md) does. A game with OBB game data then needs
|
||||
a second **Install** while you have it open on the Frame: Frame Control
|
||||
downloads the data, copies it in and closes the game.
|
||||
4. **Install and test** does the same, then starts the game itself (twice if
|
||||
it has game data to add: once to add it, once for the test), watches it for
|
||||
about 30 seconds, and records what happened. **Test again** repeats only
|
||||
the test.
|
||||
5. Choose **Install** on as many games as you like: they wait in the
|
||||
**Install queue** and install one after another, with Quest imports and
|
||||
Android store installs. **Remove** takes one out before it starts.
|
||||
6. **Disconnect** deletes the token from your computer and, in the desktop
|
||||
app, signs out of Meta in Frame Control (clears the sign-in window's
|
||||
storage). Games already on the Frame stay.
|
||||
|
||||
From a terminal (the token is read from standard input, never an argument):
|
||||
|
||||
```sh
|
||||
python3 ui/frame_meta.py connect --token-stdin # paste the token, press Enter
|
||||
python3 ui/frame_meta.py status
|
||||
python3 ui/frame_meta.py list [--refresh] # owned Quest games, with community verdicts
|
||||
python3 ui/frame_meta.py download APP_ID # into the local install cache (a later download clears it after 6 hours)
|
||||
python3 ui/frame_meta.py install APP_ID [--test] [--allow-downgrade]
|
||||
python3 ui/frame_meta.py test com.example.game [--wait 30] [--no-report]
|
||||
python3 ui/frame_meta.py disconnect
|
||||
```
|
||||
|
||||
The local API: `GET /api/meta` (connected or not, never the token),
|
||||
`GET /api/meta?library=1[&refresh=1]` (the games), and `POST /api/meta` with
|
||||
`{"action": "connect", "token": "...", "acknowledged": true}`,
|
||||
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
|
||||
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
|
||||
test return a background job (`/api/job`); an install waits its turn in the
|
||||
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
|
||||
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
|
||||
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
|
||||
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
|
||||
was cancelled while it was checked answers 409 and saves nothing;
|
||||
`cancel-signin` for an attempt whose token is the saved account deletes it.
|
||||
|
||||
## What an install does
|
||||
|
||||
1. Checks the game is in your library (by its Meta app id), and that there's
|
||||
room on this computer for the APK, a patched copy of it and the game data.
|
||||
2. Downloads the APK from Meta's download server, within the size Meta
|
||||
declared (and at most 2 GB), checking its SHA-256 if Meta gives one, and
|
||||
that it is an APK of the package and version Meta said.
|
||||
3. Runs Import from my Quest's checks. Refused: 32-bit-only builds (no
|
||||
`arm64-v8a`), and apps that need an Android newer than Lepton's (API 30).
|
||||
Installed with a warning: games that use Meta's licence check
|
||||
(`libovrplatformloader.so`), and VrApi-only games ("may not run").
|
||||
4. Installs it with `frame_android.install` (VR detection, the launcher fix,
|
||||
the OpenXR compatibility layer, artwork, a Steam library entry). An older
|
||||
copy never silently replaces a newer one on the Frame unless you tick
|
||||
**Allow replacing a newer version**.
|
||||
5. Game data: the app's OBB (saved as Meta names it when that is a standard
|
||||
`main|patch.<version>.<package>.obb` name, otherwise as
|
||||
`main.<version code>.<package>.obb`) and any *required* asset files with
|
||||
standard OBB names, at most 8 GB each. One install downloads at most 16 GB
|
||||
in all, APK included, counted as the bytes arrive (so files whose size Meta
|
||||
doesn't give count too); a file that goes past it is stopped and removed.
|
||||
The data is copied into the
|
||||
running game by the same two-step helper Import from my Quest uses. Other
|
||||
asset files (DLC packs, `.pak` files a game fetches through Meta's
|
||||
Platform SDK) aren't downloaded; the result lists them.
|
||||
6. Deletes everything it downloaded, whether the install worked or not.
|
||||
Downloads left by a crash are removed after six hours.
|
||||
|
||||
## The test, and the report
|
||||
|
||||
The test starts the game, waits for its Lepton instance, then looks every 10
|
||||
seconds for 30 seconds: is the game's process alive, and what does its Android
|
||||
log (main, system and crash buffers) say. It recognises:
|
||||
|
||||
| In the log | Cause given |
|
||||
|---|---|
|
||||
| "entitlement check failed", "not entitled", `ovr_Entitlement…` or `IsUserEntitledToApplication` reporting a failure (a call that passes doesn't count) | Meta's licence check failed |
|
||||
| Platform SDK initialisation errors | Meta's Platform SDK couldn't start |
|
||||
| `Failed to launch SystemActivities`, "device not supported" | The game checks it is on a Meta Quest |
|
||||
| `vrapi_… was called before…`, VrApi load errors | Legacy VrApi |
|
||||
| `XR_ERROR_EXTENSION_NOT_PRESENT` (with the extension's name) | An OpenXR feature the Frame lacks |
|
||||
| `XR_ERROR_API_VERSION_UNSUPPORTED`, runtime/initialisation failures | OpenXR version or session problems |
|
||||
| `VK_ERROR_DEVICE_LOST`, `zink`, `libgallium_dri.so`, `GL_INVALID_…`, shader failures | Graphics driver errors |
|
||||
| `ClipboardManager` | Lepton's missing clipboard service |
|
||||
| `NoSuchMethodError`, `UnsatisfiedLinkError`, `dlopen failed` | Android or library features Lepton lacks |
|
||||
| `FATAL EXCEPTION`, `Fatal signal`, `SIGSEGV` | A crash |
|
||||
| `ANR in …`, "Input dispatching timed out" | Not responding |
|
||||
| `XR_SESSION_STATE_SYNCHRONIZED/VISIBLE/FOCUSED`, `xrEndFrame` | The VR scene started |
|
||||
|
||||
The outcome is one of **runs** or **runs with issues** (its VR scene
|
||||
started), **quits** (closed by itself), **crashes**, **hangs** (Android
|
||||
reported it not responding; Frame Control then closes it, and says so if it
|
||||
couldn't), **didn't start** (its instance never came up), or **no clear
|
||||
result**: it stayed open but its log never showed its VR scene starting. That
|
||||
can be a game still loading, or one stuck after a failed check; Frame Control
|
||||
can't tell, so it doesn't record it. A game already open is closed first, so
|
||||
the test starts clean; if it can't be closed, nothing is tested. If the Frame
|
||||
fails while the test reads the game or its log, the test says only that it
|
||||
couldn't read the log: a failed command's output can be the game's log, so it
|
||||
is never shown. Signatures look at the first 4 KB of each log line and are
|
||||
bounded, so a huge or hostile log can't make the test slow. They
|
||||
are written for Frame Control from the research in [vr-apks.md](vr-apks.md);
|
||||
they are not copied from other tools.
|
||||
|
||||
The result goes through the same path as **Test** and **Report**
|
||||
(`frame_catalog.add_report`): `via` probe, source `meta-store`, the result
|
||||
(`runs`, `crashes` or `instance_failed`, the values the database accepts), and
|
||||
notes with the outcome, the cause, the names of the matched signals and the
|
||||
version code, all fixed texts. Nothing else: no token, account name, file or
|
||||
log text. The page may show a short name from the log next to the cause (an
|
||||
OpenXR extension, a library, an exception class or a signal), taken from the
|
||||
log line after the token is removed from it, and left out unless it looks like
|
||||
a real name (64 characters at most, no long mixed letter-and-digit runs);
|
||||
reports never carry it. It is always kept on this
|
||||
computer; it reaches the shared database only if **Share compatibility
|
||||
results** is on in Privacy settings, as for every report. The panel offers to
|
||||
turn that on.
|
||||
|
||||
## Your token and privacy
|
||||
|
||||
- Kept in `meta-account.json` in Frame Control's per-user data folder
|
||||
(`~/Library/Application Support/Frame Control` on macOS,
|
||||
`%APPDATA%\Frame Control` on Windows, `~/.local/share/frame-control` on
|
||||
Linux), written owner-only (0600) through a temporary file. Never on the
|
||||
Frame.
|
||||
- Sent only to `graph.oculus.com` (in the POST body) and
|
||||
`securecdn.oculus.com` (in the download address, which is how that server
|
||||
takes it), over HTTPS. A download that redirects elsewhere (Meta's CDN) is
|
||||
followed only to a plain HTTPS address on port 443 that doesn't carry the
|
||||
token in any form, anywhere (host, path, query or fragment; literal,
|
||||
recased, percent-encoded, or 8+ characters of it), nor an `access_token`
|
||||
parameter. That is checked before any lookup or connection, and a redirect
|
||||
that fails it is refused. API answers that redirect are refused.
|
||||
- Every connection goes to an address checked to be public (an allow-list:
|
||||
no private, local, shared, reserved, multicast, NAT64, 6to4 or Teredo
|
||||
addresses), and to exactly that address, with TLS checked for the host
|
||||
name, so a second DNS answer can't swap in another. System proxy settings
|
||||
aren't used. This is the same design as the itch.io purchases branch.
|
||||
- Never in logs, error messages, API answers, the page or reports: every
|
||||
message is cleaned first, matching the token (or any start of it of 8+
|
||||
characters) in any letter case, with each character written literally or
|
||||
percent-encoded once or twice (%XX, %25XX) in either case, and any other
|
||||
token-shaped value, before it is shortened.
|
||||
- Disconnect deletes the file and the library Frame Control holds in memory,
|
||||
and stops a download under way. A connect still being checked when you
|
||||
disconnect is not saved. In the desktop app, before the token is deleted, it
|
||||
also stops any sign-in under way, destroys the sign-in window, waits for a
|
||||
connect still under way to finish and cancels its attempt (deleting its
|
||||
token if it was just saved), and only then clears the `persist:meta-signin` session
|
||||
(cookies, storage, cache); no other session is touched. Sign in with Meta
|
||||
stays off from the moment you press Disconnect until the token is deleted,
|
||||
and the app refuses a sign-in while it is signing out.
|
||||
- With Sign in with Meta, a second copy of your Meta sign-in lives in that
|
||||
session's storage, in Electron's per-user data folder (`Partitions/meta-signin`),
|
||||
until you disconnect or sign out of Meta in Frame Control.
|
||||
|
||||
## Where the protocol comes from
|
||||
|
||||
Frame Control's code is its own (MIT). These public sources describe the
|
||||
protocol; Frame Control takes only facts from them (addresses, query ids,
|
||||
field names), not code. QAVS and BSManager are GPL-3.0; OculusGraphQLApiLib,
|
||||
DownQuest and Oculus-downgrader state no licence.
|
||||
|
||||
| Fact | Source |
|
||||
|---|---|
|
||||
| GraphQL at `https://graph.oculus.com/graphql`, POST, form fields `access_token`, `doc_id`, `variables`; `OAuthException` / code 190 for a bad token | [OculusGraphQLApiLib `GraphQLClient.cs`](https://github.com/ComputerElite/OculusGraphQLApiLib/blob/866081e2/OculusGraphQLApiLib/GraphQL/GraphQLClient.cs), [DownQuest `DownQuest.js`](https://github.com/ptrpaws/DownQuest/blob/50f5d558/DownQuest.js) |
|
||||
| "Who am I": `doc_id` 4149322231793299, `data.viewer.user {id, alias}` | OculusGraphQLApiLib `GraphQLClient.cs` and `Results/User.cs` |
|
||||
| Owned apps: `doc_id` 4850747515044496, `data.viewer.user.active_entitlements.nodes[].item` | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/Entitlement.cs`, `Results/Application.cs`; used by [QAVS `GameDownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/GameDownloadManager.cs) |
|
||||
| One binary: `doc_id` 4734929166632773 with `binaryID`; fields `package_name`, `version`, `version_code`, `size`, `obb_binary`, `asset_files` (`file_name`, `size`, `is_required`) | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/OculusBinary.cs`, `Results/AssetFile.cs` |
|
||||
| Downloads: `https://securecdn.oculus.com/binaries/download/?id=<id>&access_token=<token>`, the same for the APK, `obb_binary` and asset files; QAVS takes the OBB and required asset files | OculusGraphQLApiLib `Game/GameDownloader.cs`, [QAVS `DownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/DownloadManager.cs), DownQuest `DownQuest.js` |
|
||||
| Token checks (`OC`/`FRL`, no `%`, `|`, `:`, not `OC` + 15 digits) | OculusGraphQLApiLib `GraphQL/TokenTools.cs`, [BSManager `oculus.helpers.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/shared/helpers/oculus.helpers.ts) |
|
||||
| How users get a token (`oc_ac_at` cookie, or the PC app's `FRL` token) | [BSManager wiki](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token) |
|
||||
| Sign in with Meta: open `https://secure.oculus.com` in an app window, and once it reaches `https://secure.oculus.com/my/profile` read the `oc_ac_at` cookie from that window's session (checked on `did-navigate`, `did-navigate-in-page`, `did-stop-loading`); 10-minute timeout | [BSManager `bs-oculus-downloader.service.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/main/services/bs-version-download/bs-oculus-downloader.service.ts) (`getUserTokenFromMetaAuth`) |
|
||||
|
||||
**Assumed, not seen in those sources:** that the entitlement and binary
|
||||
answers carry the fields above in the shapes the libraries' models describe
|
||||
(the stored queries are opaque); that the list isn't paginated (none of the
|
||||
tools page it); that `ANDROID_6DOF` marks Quest apps; where downloads redirect
|
||||
to (the tools let their HTTP client follow redirects; Frame Control follows
|
||||
up to five, to any public HTTPS host, and refuses one carrying the token); and
|
||||
a `sha256`
|
||||
field (none was seen, so in practice only sizes are checked). The log
|
||||
signatures for the test are inferred from the compatibility research, not
|
||||
from a real Frame.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified locally** with a fake Meta and a fake Frame (`tests/test_meta.py`):
|
||||
token checks; storage owner-only through a temporary file; redaction (the
|
||||
stored token recased and percent-encoded in either case, starts of it, other
|
||||
token-shaped values, and redaction before cutting); HTTPS-only URLs; the
|
||||
public-address allow-list, resolving once and connecting only to a checked
|
||||
address within one deadline; redirects off Meta's hosts refused when they
|
||||
carry the token anywhere, before any lookup; the token never sent to the
|
||||
CDN; GraphQL redirects refused; disconnect beating a connect in flight and
|
||||
stopping a download; library parsing (Quest apps only, nothing expired or
|
||||
inactive, details from the binary query); downloads with an OBB; size, length
|
||||
and checksum limits and the whole-install budget with nothing left behind;
|
||||
refusing apps not in the
|
||||
library; the handoff to Import from my Quest's checks, install and data step;
|
||||
the licence-check warning instead of a refusal; test outcomes from log
|
||||
fixtures (a passing licence check isn't a failure; no VR scene is no clear
|
||||
result, not recorded); closing before a test and saying when a close failed;
|
||||
reports carrying only fixed texts, no token, log text or file names. The
|
||||
real TLS connection code is the same as the itch.io branch's and is exercised
|
||||
here only with a fake connection.
|
||||
|
||||
**Sign in with Meta, verified locally** with fake Electron windows, sessions
|
||||
and cookies (`app/test/meta-signin.test.js`): the window's options (its own
|
||||
partition, sandbox, context isolation, no Node, no preload) and Meta's login
|
||||
address, no developer tools; permission requests and checks refused; the
|
||||
navigation allow-list (links elsewhere open in the system browser, redirects
|
||||
elsewhere are stopped, pop-ups never become windows); frames allowed any
|
||||
HTTPS page but no other scheme, the window itself only Meta's sites; a
|
||||
page's beforeunload not keeping the window open; the cookie read only from
|
||||
`persist:meta-signin` and only for `oculus.com`, even when the default
|
||||
session and another partition hold one; signed-out values ignored; a value
|
||||
sent once, a refused one keeping the window open; the window closing after a
|
||||
connect, with the page getting the answer and never the token; nothing
|
||||
logged, and a server error that echoed the token scrubbed; closing the window
|
||||
or signing out ending the wait; closing, timing out or signing out during a
|
||||
connect cancelling that attempt (with its own id) after cookie intake stops
|
||||
and only after that connect settles (saved, refused or failed), and no cancel
|
||||
when nothing was sent; sign-out (Disconnect's first step) waiting for a
|
||||
connect under way, even from a window closed earlier, before it finishes;
|
||||
sign-out destroying the window before clearing only that partition; no
|
||||
sign-in starting while a sign-out runs (it is refused: "try again in a
|
||||
moment"), a second sign-out joining the first, and a sign-out waiting for
|
||||
every window and connect started before it. The
|
||||
frame redirects held to the frame rule, the window's to Meta's sites. The
|
||||
server side (`tests/test_meta.py`): an attempt cancelled while Meta is
|
||||
answering not saved, even after 300 other cancels; a cancel just after the
|
||||
save deleting that token; a cancel never removing a later sign-in's or a
|
||||
pasted token's account; unknown attempts changing nothing; no attempt left
|
||||
listed after a connect, refused or not; attempt ids checked.
|
||||
|
||||
**Differences from BSManager:** BSManager uses its app's default session (and
|
||||
clears only `.oculus.com` cookies afterwards); Frame Control uses a session of
|
||||
its own and clears all of it on disconnect. BSManager waits for
|
||||
`/my/profile`; Frame Control also reacts to the cookie changing, and to any
|
||||
`oculus.com` page, and lets the server decide whether the value is a
|
||||
working token.
|
||||
|
||||
**Not verified:** Sign in with Meta against Meta's real login page (no real
|
||||
Meta account was used, and nothing was sent to Meta): whether Meta's login
|
||||
still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether
|
||||
it uses pop-ups (they open in the same window here, which would break a
|
||||
pop-up that reports back to its opener), whether `oc_ac_at` is still set on
|
||||
`oculus.com` after login, and whether Meta accepts the window (Frame Control
|
||||
removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was
|
||||
available; nothing was sent to Meta), real downloads and their redirects,
|
||||
real Quest games installed or tested on a real Frame, and whether the log
|
||||
signatures match what real games print in Lepton.
|
||||
+85
-15
@@ -1,11 +1,14 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
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
|
||||
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
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**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”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| 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. |
|
||||
| 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, 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
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
|
||||
|
||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
||||
|
||||
## 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
|
||||
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)
|
||||
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),
|
||||
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),
|
||||
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)
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
||||
separate from Frame Control. Public reports describe some Proton support;
|
||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||
and sources are in [VR utilities](vr-utilities.md).
|
||||
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
||||
headset creates its own gamescope panel using built-in tools. It needs no
|
||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# PC in the headset
|
||||
|
||||
Windows and Linux hosts use **Tools → PC in the headset**. The host shares a
|
||||
window or screen, and the existing Frame viewer makes it a SteamVR panel.
|
||||
Move it with the dashboard's Float in World, Move and Size controls.
|
||||
|
||||
**Inferred / not yet verified on a desktop host:** the Windows and Linux
|
||||
capture and input paths below. This work was developed on a Mac with no
|
||||
Windows or Linux desktop VM. A native build or test-pattern test in CI does
|
||||
not establish that desktop capture, a permission dialog, hardware encoding
|
||||
or laser input works. Keep this feature in the draft/testing stage until
|
||||
those paths have been tried on real hosts.
|
||||
|
||||
## Own implementation, platform APIs and bundled libraries
|
||||
|
||||
Frame Control owns the host agent, input routing, authentication, streaming
|
||||
protocol, panel launcher and adaptation. It does not launch or require
|
||||
Sunshine, OBS or another desktop-streaming app. GStreamer and its codec
|
||||
plugins are ordinary libraries bundled with the Windows and Linux app;
|
||||
users do not install a GStreamer application. The shared library build keeps
|
||||
license texts and package provenance alongside the libraries. Linux also
|
||||
bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a
|
||||
private client configuration; it does not change the desktop’s configuration.
|
||||
|
||||
First-party alternatives considered (**documented**): Valve Remote Play
|
||||
streams a game/desktop, rather than providing this per-window panel protocol;
|
||||
Windows Remote Desktop opens a remote session; Linux's desktop portal is the
|
||||
consent mechanism for sharing the current desktop. The chosen paths are:
|
||||
|
||||
| Host | Capture | Encoding | Input |
|
||||
|---|---|---|---|
|
||||
| Windows | Windows.Graphics.Capture, through `d3d11screencapturesrc capture-api=wgc`; HWND or HMONITOR | Hardware Media Foundation (`mfh264enc`), low latency, no B-frames | `SendInput`, with source bounds and per-monitor DPI awareness |
|
||||
| Linux | RemoteDesktop + ScreenCast portal, then the returned PipeWire fd/node | VA-API (`vah264enc`) where registered; x264 otherwise | RemoteDesktop portal notifications, using only granted pointer/keyboard devices |
|
||||
|
||||
API choices are **documented**, not device verification:
|
||||
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
|
||||
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
|
||||
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html),
|
||||
[ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html),
|
||||
[RemoteDesktop portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html).
|
||||
|
||||
Windows needs a WGC-capable Windows 10/11 desktop and an available hardware
|
||||
Media Foundation H.264 encoder. Elevated windows and the secure desktop
|
||||
cannot be driven by an ordinary Frame Control process. If Windows refuses
|
||||
to focus a selected window, input is refused rather than sent to the app
|
||||
covering it; bring the selected window forward, then Stop and Show again. Minimized/closed
|
||||
windows may stop producing frames. Protected content is not supported.
|
||||
|
||||
On Linux, press **Choose a window or screen…** and approve the desktop's
|
||||
sharing dialog. Choose another source to add another panel. Stop releases
|
||||
that source's portal session; sharing it again asks for consent again.
|
||||
A desktop must implement both ScreenCast and RemoteDesktop for this path;
|
||||
a ScreenCast-only compositor cannot provide laser input through this API.
|
||||
Cancelling or denying a dialog is reported on the card. No portal permission
|
||||
is bypassed, and Frame Control does not open `/dev/uinput` or the unrestricted
|
||||
PipeWire daemon on the host.
|
||||
|
||||
The host's own keyboard still works. Input from the viewer uses normalized
|
||||
picture coordinates, maps through the selected source's bounds, and releases
|
||||
held buttons/keys on blur, disconnect and Stop. Linux requires the pointer
|
||||
and keyboard grants. **Untested:** desktop-specific consent, mixed-DPI
|
||||
Windows input alignment, multi-monitor layouts, hardware encoder behavior,
|
||||
window resize/minimize, and non-US keyboard layouts.
|
||||
|
||||
## Shared pieces
|
||||
|
||||
- `ui/frame_macview.py` owns the SSH tunnel, reconnect supervision, quality
|
||||
presets and panel launch for all hosts. `ui/frame_pcview.py` selects the PC
|
||||
helper; `/api/macview` remains the compatible endpoint.
|
||||
- `ui/mac-view.html` is the one viewer. The 17-byte big-endian frame header,
|
||||
Annex-B H.264/JPEG payloads, `hello`/`ack` reconnect handshake, clock sync,
|
||||
`rx`/`fd` timing reports and input messages are unchanged.
|
||||
- `desktop/controller.c` is the rate controller shared by the Mac Swift
|
||||
binding and the PC Python binding. Capture is gated **before** encoding;
|
||||
encoded reference frames are never discarded. A bounded raw-frame queue
|
||||
keeps the newest picture, including the last update of an idle window,
|
||||
until the gate opens. A native one-frame-source test covers that case.
|
||||
It keeps the Mac's bitrate demand protection and tier hysteresis.
|
||||
- PC records use the existing `Stats.swift` JSON schema, with bounded
|
||||
4096-frame/512-input storage in `ui/frame_stream_stats.py`. The benchmark's
|
||||
analysis, targets and network shaping are shared, not reimplemented.
|
||||
Capture timestamps describe the native pipeline's source time; they do
|
||||
not prove the time at which the host compositor displayed the pixels.
|
||||
- Mac virtual-display separation remains Mac-only. Windows WGC and the
|
||||
Linux portal share the selected window directly.
|
||||
|
||||
PC capture follows the shared frame-rate and resolution tiers. x264 updates
|
||||
bitrate while running. Hardware encoders are drained and reopened when the
|
||||
budget changes materially, at most once a second, because their live property
|
||||
support varies. Reopening starts a new keyframe and retains the consented
|
||||
portal session. **Untested:** hardware reconfiguration latency and whether a
|
||||
particular desktop permits reconnecting its PipeWire stream this way.
|
||||
|
||||
## Build and measure
|
||||
|
||||
Packaged Windows/Linux builds include `desktop/bundle/pc-host` and its shared
|
||||
libraries. Source checkouts build them with `python3 desktop/build.py` after
|
||||
installing GStreamer development packages (see the `PC host libraries` CI
|
||||
workflow). The feature reports a missing bundle; it does not download or
|
||||
install a streaming app on first use.
|
||||
|
||||
The existing benchmark now accepts a PC host:
|
||||
|
||||
```sh
|
||||
python3 scripts/macview-bench.py run --pc --scenario test --label pc-test
|
||||
# Linux: select a real source in the desktop's sharing dialog
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source choose --label linux-window
|
||||
# Windows: use the HWND/monitor source ID shown by the host's /windows or /displays
|
||||
python3 scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
|
||||
```
|
||||
|
||||
The synthetic PC pattern uses bundled x264 so headless CI can verify the
|
||||
wire protocol without claiming that a GPU was exercised. The `capture`
|
||||
scenario measures the selected real source without injecting input or
|
||||
assuming that it animates at 60 fps. Mac-only Chrome/virtual-display typing
|
||||
and scrolling automation is not run on PC hosts. Results retain the same
|
||||
latency stages and record `host_platform`, `pc_host` and `source`. CPU sampling
|
||||
on PC hosts is explicitly unavailable. `--net` and `--delay` still use the
|
||||
same bounded shaping relay, without administrator privileges.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **Verified, Mac, 2026-09-28:** the final full unit/integration suite ran
|
||||
183 tests successfully, including the real Mac helper's H.264, ticket, timing
|
||||
and input-echo tests. The native PC test class was skipped locally because
|
||||
its libraries were absent; the PC adapter and shared-controller tests passed.
|
||||
- **Verified, real Frame, 2026-09-28, BUILD_ID 20260925.6191901:** the base
|
||||
helper's synthetic source created panel `valve.steam.desktopgame.2001639889`,
|
||||
and the shared Chromium viewer decoded H.264. It recorded 286 frames over
|
||||
the short probe, with a two-second summary of 19.5 fps shown and total
|
||||
latency p50/p95 83/156.5 ms. This establishes the existing viewer/transport
|
||||
route, not Windows/Linux capture, input or a latency target. The probe's
|
||||
helper, tunnel and viewer were stopped afterward.
|
||||
- **Verified in CI:** native library builds and real x264 protocol tests passed
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64 in
|
||||
[run 36422214445](https://github.com/saphid/frame-control/actions/runs/36422214445).
|
||||
All four installer builds passed in
|
||||
[run 36422214425](https://github.com/saphid/frame-control/actions/runs/36422214425).
|
||||
These are build/synthetic tests, not desktop-host verification. No VM was used.
|
||||
- **Verified, real Frame, same build/date:** the ARM64 PC agent and its
|
||||
bundled libraries ran from a temporary user directory, using x264's moving
|
||||
test pattern. Traffic travelled Frame → Mac SSH relay → Frame viewer.
|
||||
The shared bench recorded 351 drawn frames, content p50/p95 15.1/80.3 ms,
|
||||
and 34.8 fps. The frame-rate and late-frame targets failed. This checks the
|
||||
new agent and real viewer together; it is not a representative PC link.
|
||||
The helper exited 0, its viewer/tunnels stopped, and its directory was removed.
|
||||
[Raw benchmark result](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
|
||||
This first probe's input echo measured message receipt to the next capture,
|
||||
not a visible pattern response; later builds make test clicks change its color.
|
||||
- **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace
|
||||
matched the original Swift controller. `tests/test_pc_controller.py` retains
|
||||
the original trace digest as a regression check.
|
||||
- **Verified, real Frame, agent at `cd20243`:** the repeated synthetic
|
||||
probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic
|
||||
input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before
|
||||
injection is timestamped. The frame-rate/late-frame targets still failed;
|
||||
this remains a Frame-hosted x264 test through a Mac relay, not a desktop or
|
||||
physical-laser measurement. Helper exit 0 and cleanup succeeded.
|
||||
[Latest device probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json).
|
||||
- **Untested:** real Windows WGC → Media Foundation → Frame; real Linux
|
||||
portal → PipeWire → VA-API/x264 → Frame; physical laser input on either.
|
||||
No benchmark numbers for those desktop paths are claimed.
|
||||
|
||||
## Independent review availability
|
||||
|
||||
A direct read-only review was attempted with
|
||||
`devin -p --model swe-2-max --permission-mode auto --prompt-file …`. The first
|
||||
attempt exited 0 after rejecting a tool that needed interactive permission;
|
||||
it did not inspect the diff. A full inline-diff attempt returned no output
|
||||
for 15 minutes and was terminated (shell exit 143). A smaller inline native
|
||||
code review returned no output within 300 seconds (process exit -15).
|
||||
SWE-2 Max was requested; no completed review or findings were received, so
|
||||
independent review is **unverified**, not a passed check. The PR remains draft.
|
||||
|
||||
## Device follow-up, 2026-09-29
|
||||
|
||||
**Verified, read-only, SteamOS BUILD_ID 20260925.6191901:** the Frame was
|
||||
reachable, charging (44% initially, 63% at the end), and SteamVR reported
|
||||
activity level 3 (standby). No Plasma desktop was running. Introspection of
|
||||
the active `org.freedesktop.portal.Desktop` service exposed ScreenCast with
|
||||
source types 3, but no RemoteDesktop interface. **Inferred:** this gamescope
|
||||
session cannot provide this feature's required consented input path; a
|
||||
ScreenCast-only session is insufficient.
|
||||
|
||||
**Blocked, not a device execution result:** the shared `/tmp/frame-test.lock`
|
||||
was held by another thread. Ten lock-acquisition attempts over five minutes
|
||||
all failed, after earlier preparation-time attempts also found it occupied.
|
||||
The current `a992f6d` ARM64 CI bundle was downloaded to the Mac, but nothing
|
||||
was installed, launched or stopped on the Frame. No new screenshots or
|
||||
current-revision device timings were obtained. The lock was not removed and
|
||||
no global settings were changed. The earlier synthetic results above remain
|
||||
valid only for their named revisions; standby visibility, physical laser
|
||||
input and worn-headset performance remain unverified.
|
||||
|
||||
**Verified, Mac:** a fresh full suite ran 309 tests successfully, with one
|
||||
native-PC class skipped. Current-revision CI passed
|
||||
[native hosts](https://github.com/saphid/frame-control/actions/runs/36504546909),
|
||||
[installers](https://github.com/saphid/frame-control/actions/runs/36504546800)
|
||||
and [general checks](https://github.com/saphid/frame-control/actions/runs/36504546887).
|
||||
+38
-14
@@ -33,15 +33,28 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
every computer, which lets us count headsets rather than installs; it can't
|
||||
be turned back into the machine-id or a serial number. `headsets_saved` is how
|
||||
many headsets this copy of Frame Control has set up.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
@@ -51,12 +64,15 @@ error becomes one category from this fixed list, plus Android's own
|
||||
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
|
||||
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
|
||||
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
|
||||
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
|
||||
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
|
||||
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
|
||||
Control's OpenXR compatibility layer, because this copy of the app is missing
|
||||
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
|
||||
names what was installed only when that's already public:
|
||||
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
|
||||
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
|
||||
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
|
||||
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
|
||||
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
|
||||
APK that installed without Frame Control's OpenXR compatibility layer,
|
||||
because this copy of the app is missing it, carries `xr_layer_missing: true`;
|
||||
otherwise that field is left out. It names what was installed only when
|
||||
that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
@@ -74,13 +90,21 @@ SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
|
||||
from [My Meta library](meta-library.md)) or the public host name of a
|
||||
download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
My Meta library (Experimental) keeps your Meta access token in
|
||||
`meta-account.json` in this data folder, owner-only, and sends it only to
|
||||
Meta's API and download hosts. It is never in analytics, reports, error
|
||||
details or logs; its test reports carry the package, version, version code,
|
||||
title, result and the reason, nothing from your account. See
|
||||
[meta-library.md](meta-library.md#your-token-and-privacy).
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
@@ -108,8 +132,8 @@ runs.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Import from my Quest
|
||||
|
||||
Move games you own from your own Meta Quest onto the Steam Frame. Plug the
|
||||
Quest into your computer by USB; Frame Control copies the game off it with
|
||||
adb, checks it can run in Lepton, and installs it on the Frame like any other
|
||||
APK, with its own Lepton instance and Steam library entry
|
||||
([apks.md](apks.md), [vr-apks.md](vr-apks.md)).
|
||||
|
||||
There is no licence-check bypass, and there won't be. A game that uses Meta's
|
||||
licence check installs with a warning: on the Frame that check can't reach
|
||||
Meta, so the game may quit, hang, or run without online features. See
|
||||
[What won't run](#what-wont-run-and-why) below. To install games you own
|
||||
straight from Meta instead, without a Quest, see [My Meta library
|
||||
(Experimental)](meta-library.md).
|
||||
|
||||
## Before you start: developer mode on the Quest
|
||||
|
||||
adb only reaches a Quest with developer mode on:
|
||||
|
||||
1. Make a free Meta developer organisation at
|
||||
[developers.meta.com](https://developers.meta.com/horizon/) on a **verified**
|
||||
Meta account (Meta asks for a phone number or payment card to verify).
|
||||
2. In the Meta Horizon phone app, open the headset's settings and switch
|
||||
**Developer Mode** on. Restart the Quest.
|
||||
3. Plug the Quest into the computer that runs Frame Control with a USB cable
|
||||
that carries data (some charging cables don't).
|
||||
4. Put the headset on. Accept **Allow USB debugging**, and tick **Always allow
|
||||
from this computer**.
|
||||
|
||||
Frame Control uses the adb it already uses for the Frame (an adb you
|
||||
installed, else the one it bundles). On Linux, adb also needs permission to
|
||||
open the Quest: add a udev rule for Meta's USB vendor id `2833`, for example
|
||||
`SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0660", GROUP="plugdev"`, and
|
||||
be in that group. Then unplug and replug.
|
||||
|
||||
The Frame's own adb connections (Frame Control's SSH tunnels to Lepton,
|
||||
`127.0.0.1:<port>`) are never touched. Only a device that reports itself as
|
||||
Oculus or Meta, or a Quest model, counts as a Quest.
|
||||
|
||||
## Using it
|
||||
|
||||
In the app: **Android → On your Frame → Import from my Quest**. Choose **Look
|
||||
for my Quest**, then **Import** next to a game. Tick **Also copy each game's
|
||||
files in Android/data** to bring the game's external files too (some games
|
||||
keep saves there). Progress shows in the panel; the result and any reason it
|
||||
stopped appear in the message log.
|
||||
|
||||
You can choose **Import** on several games at once: they wait in the **Install
|
||||
queue** (above the Quest panel) and import one after another, together with
|
||||
installs from the Android store and My Meta library. **Remove** takes a game
|
||||
out of the queue before it starts. Each queued import runs on the headset that
|
||||
was selected when you queued it; if you switch headsets first, it stops with a
|
||||
message instead.
|
||||
|
||||
A game with game data (an OBB folder, or Android/data when ticked) takes two
|
||||
imports, because Android can only take that data while the game's Lepton
|
||||
instance is running, and there is no way to start the instance without the
|
||||
game. Frame Control never starts the game itself:
|
||||
|
||||
1. **Import** installs the game and says it has data to add.
|
||||
2. Start the game from the Steam library and leave it on its first screen.
|
||||
3. **Import** again. Frame Control sees the game open with the same version,
|
||||
copies the data in, and closes the game. Start it again to play.
|
||||
|
||||
A game that quits straight away without its data can't take it this way.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_quest.py devices # what adb sees, and which is the Quest
|
||||
python3 ui/frame_quest.py list # apps on the Quest: split or not, OBB and data sizes
|
||||
python3 ui/frame_quest.py import com.example.game # copy one to the Frame
|
||||
python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
|
||||
```
|
||||
|
||||
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
|
||||
its apps) and `POST /api/quest` with
|
||||
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
|
||||
which returns a background job (`/api/job`) with a stage and percentage. The
|
||||
job waits in the install queue first (`"queued": true`, stage `Queued: N
|
||||
installs before it`). `GET /api/queue` lists the running install and those
|
||||
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
|
||||
waiting one (409 once it has started).
|
||||
|
||||
## What an import does
|
||||
|
||||
1. Finds the Quest (`adb devices -l`, then `ro.product.manufacturer` and
|
||||
`ro.product.model`). If none is ready it says why: nothing connected, not
|
||||
authorised yet (the **Allow USB debugging** prompt), no USB permission
|
||||
(Linux), offline, another Android device, or several Quests.
|
||||
2. Lists third-party apps (`pm list packages -3`), each with `pm path` (one
|
||||
line is a single APK, several a split APK) and the size of
|
||||
`/sdcard/Android/obb/<package>` and `/sdcard/Android/data/<package>`.
|
||||
Labels aren't shown before import: reading them means copying the APK.
|
||||
3. Checks there is room on the computer (the APKs, a merged and a patched copy
|
||||
of them counted uncompressed, the OBB folder and the Android/data folder
|
||||
once, since that is streamed to the Frame), then copies the APK and its splits
|
||||
into a per-user cache folder (`Frame Control/quest-import` under the
|
||||
system's cache folder).
|
||||
4. **Checks the APK before anything else is copied**, refusing:
|
||||
- 32-bit-only builds (native code but no `arm64-v8a`);
|
||||
- apps that need an Android newer than Lepton's (API 30);
|
||||
- split APKs Frame Control can't merge (below).
|
||||
Installed with a warning:
|
||||
- games that use Meta's licence check (Meta's Platform SDK,
|
||||
`libovrplatformloader.so`, in the base or any split): "uses Meta's
|
||||
licence check; it may quit, hang, or run without online features on the
|
||||
Frame. Frame Control won't remove the check." Linking the SDK doesn't
|
||||
mean a game enforces the check: Meta's store requirement for it
|
||||
(VRC.Quest.Security.1) has only been *Recommended* since 2021, and what
|
||||
a game does when the check fails is up to the game;
|
||||
- VrApi-only builds (`libvrapi.so`, no OpenXR loader), flagged as for any
|
||||
APK: the Frame has no VrApi, so they may not run.
|
||||
5. If the game isn't open on the Frame: installs it through the normal APK
|
||||
install (`frame_android.install`: VR detection, the launcher fix, the
|
||||
OpenXR compatibility layer, artwork), and says whether it has game data
|
||||
to add. An older Quest copy never replaces a newer version on the Frame
|
||||
(compared by version code, which installs now record; for installs made
|
||||
before that, it is read from the installed APK on the Frame) unless you
|
||||
tick **Allow replacing a newer version** (`--allow-downgrade`,
|
||||
`"allowDowngrade": true`). If the installed version can't be read at all,
|
||||
that permission is needed too.
|
||||
6. If the game is open on the Frame (the second import): refuses unless
|
||||
Frame Control's install record is for this package, in this package's own
|
||||
instance, at the Quest's exact version code (an open game is never
|
||||
replaced), otherwise copies the OBB folder (and `Android/data` if ticked)
|
||||
from the Quest, waits for Android to have finished booting, copies standard
|
||||
`main|patch.<version>.<package>.obb` files with the existing OBB helper
|
||||
(checked by SHA-256 inside Android, [sidequest.md](sidequest.md#obb-files))
|
||||
once Android's own `dumpsys package` in that instance shows the same
|
||||
package at the same version code,
|
||||
streams the `Android/data` files in as a tar written straight into the SSH
|
||||
transfer, then closes the game so its next start reads them, and checks
|
||||
its instance has stopped; if it hasn't, or the check itself fails (a
|
||||
failed `podman ps` never reads as "stopped"), the result says so. The game
|
||||
is closed whatever happens during the copy, including a local read error
|
||||
or Ctrl-C, and a failed close is reported without hiding the original
|
||||
error. Everything
|
||||
from the second check of the install record (after the pulls, which can
|
||||
be long) to closing the game holds the same lock as installs and removals.
|
||||
Other files in the OBB folder are listed as not copied.
|
||||
7. Deletes the cache folder, whether the import worked or not. A folder left
|
||||
by a crash is removed by a later import after six hours.
|
||||
|
||||
Only one import runs at a time. If copying game data fails, the game is
|
||||
closed and the message says so; start it and import again.
|
||||
|
||||
### Split APKs
|
||||
|
||||
Lepton runs one APK per instance, so the shared install path now merges a
|
||||
split APK into one (`frame_android.install(base, splits=[...])`, or
|
||||
`python3 ui/frame_android.py install base.apk split1.apk ...`). Merging copies
|
||||
each split's native libraries and assets into the base, drops the base
|
||||
manifest's `isSplitRequired` flag, and re-signs with Frame Control's key, as
|
||||
the launcher fix already does. Only splits that hold nothing but
|
||||
`lib/<abi>/*.so`, `assets/` and their own signature, manifest and an empty
|
||||
resource table are merged. Refused, with the reason, before anything reaches
|
||||
the Frame: splits with resources (screen density or language), code
|
||||
(`classes*.dex`), any other file, a manifest that declares components or
|
||||
libraries (`<service>`, `<uses-library>`...), feature splits, and splits of
|
||||
another package. The merged files are held in memory while the APK is
|
||||
repacked and signed (as for every patched APK), so a merge adds at most
|
||||
512 MB per file and 1 GB in all. Each file is read from its raw bytes and
|
||||
checked against both of its ZIP headers: the local header must name the same
|
||||
file with the same method, sizes and checksum as the central directory (or a
|
||||
data descriptor must carry them; ZIP64 sizes are understood), the data must
|
||||
end before the next entry, a stored file must be exactly its declared size, a
|
||||
compressed one must end exactly at the end of its data with nothing after it,
|
||||
and its checksum must match. So a forged header can't truncate a file or fill
|
||||
memory. A resource table counts as empty only
|
||||
if it defines nothing at all (styles and other complex entries included). A merged, re-signed APK can't update a
|
||||
copy signed by the publisher.
|
||||
|
||||
## What won't run, and why
|
||||
|
||||
- **Games that enforce their Meta store licence.** Many store games link
|
||||
Meta's Platform SDK (`libovrplatformloader.so`), for the licence check and
|
||||
for user ids, achievements, multiplayer, cloud saves or DLC. On the Frame the
|
||||
SDK can't reach Meta's services, which only exist on Meta headsets. A game
|
||||
that enforces the check then quits or hangs; one that doesn't may run
|
||||
without its online features. Frame Control can't tell which from the APK, so
|
||||
it installs these with a warning and **won't remove or work around the
|
||||
check**, and doesn't bundle, call or link to any tool that does. If yours
|
||||
quits, ask the developer for a build without the check, or get the game from
|
||||
Steam or itch.io. Most SideQuest, itch.io and open-source games don't link
|
||||
the SDK.
|
||||
- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only. Refused.
|
||||
- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi. They
|
||||
install with a warning, but may not run.
|
||||
- **Anything else that needs Meta**: avatars, Quest-only device checks,
|
||||
passthrough and other `XR_FB_*` features SteamVR lacks
|
||||
([vr-apks.md](vr-apks.md#what-steamvrs-android-runtime-supports-verified)).
|
||||
- **Private app data** (`/data/data/<package>`) can't be read over adb without
|
||||
root, so only saves in `Android/data` come across.
|
||||
|
||||
What usually works: OpenXR games without Meta's licence check, for example
|
||||
open-source and itch.io builds you sideloaded onto the Quest.
|
||||
|
||||
Only import games you own, for your own use.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`):
|
||||
device detection (Quest, the Frame's tunnel, the Frame over USB, other
|
||||
Android devices, unauthorised, no permissions, offline, none, several),
|
||||
package listing and split detection, the refusals and the licence-check warning, the order of copies
|
||||
(APK checked before the OBB is copied), the two-step install then data copy
|
||||
(the game is never started by Frame Control, never replaced while open, and
|
||||
closed after its data is copied), split merging and signing, the split
|
||||
refusals and size limits (including a forged zip bomb), the space estimate,
|
||||
and cache clean-up after success and failure.
|
||||
|
||||
**Not verified:** a real Quest (none was available), Quest system apps in
|
||||
`pm list packages -3`, real split APKs from the Meta Store, OBB and
|
||||
`Android/data` writes into a running Lepton instance on a real Frame
|
||||
(the OBB helper itself is also unverified there), whether games stay open
|
||||
without their OBB long enough for the second import, and the Linux udev rule.
|
||||
Large games are copied into memory while being merged or patched, as every
|
||||
patched APK already is.
|
||||
@@ -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
|
||||
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.
|
||||
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,
|
||||
`downloadable: False`, empty versions/tags/headsets and the `images` shape
|
||||
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
|
||||
|
||||
+3
-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.
|
||||
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
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| 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
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
+115
@@ -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 |
|
||||
| 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 |
|
||||
| [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
|
||||
|
||||
@@ -15,6 +16,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
@@ -163,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
|
||||
Steam accepts.
|
||||
|
||||
## PC panel host tests
|
||||
|
||||
`tests/test_pcview.py` checks source-bound tickets, reconnect/Stop revocation,
|
||||
input release, source-coordinate mapping and use of the existing panel
|
||||
launcher. The `PC host libraries` workflow builds the bundled native adapter
|
||||
on Windows, Ubuntu x64 and Ubuntu ARM64, then runs a real x264 synthetic
|
||||
stream through the HTTP/WebSocket agent. `FRAME_PC_REQUIRE_NATIVE=1` makes a
|
||||
missing native bundle fail CI instead of skipping. These tests do not grant a
|
||||
portal dialog, capture a desktop, exercise a hardware encoder or validate
|
||||
laser alignment. See [PC host evidence](pc-in-headset.md#evidence).
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
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.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
|
||||
+10
-3
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -214,9 +214,16 @@ source-search endpoint remain unverified (the public install seam is tested).
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||
services. Frame Control won't work around that.
|
||||
services. Frame Control won't work around that: no patching, no stub
|
||||
libraries, no OVRPort or FramePort. Linking the SDK doesn't mean an app
|
||||
enforces the check (Meta's VRC.Quest.Security.1 has only been
|
||||
*Recommended* since 2021, and a failed check does nothing by itself; the app
|
||||
decides), so [Import from my Quest](quest-import.md) and [My Meta
|
||||
library](meta-library.md) install these with a warning: they may quit,
|
||||
hang, or run without online features.
|
||||
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||
not a patch.
|
||||
not a patch. They install with a warning that they may not run: some
|
||||
OpenXR games ship `libvrapi.so` without depending on it.
|
||||
|
||||
## Frame Control does this for you
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
||||
|
||||
## 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**
|
||||
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
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
| 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
|
||||
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
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
@@ -18,7 +18,7 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
@@ -26,3 +26,5 @@
|
||||
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
|
||||
- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
|
||||
@end
|
||||
|
||||
#include "../../../desktop/controller.h"
|
||||
@@ -1,234 +1,45 @@
|
||||
// Adapts each stream to its network, latency first. The viewer acknowledges
|
||||
// every frame as it arrives ("rx"); from those acks the controller knows how
|
||||
// long frames take to get through and how fast the link delivers them.
|
||||
//
|
||||
// - The gate: a new frame is sent only while the oldest unacknowledged one is
|
||||
// younger than the path's usual round trip plus a little slack. So frames
|
||||
// never queue up in SSH, TCP or the Wi-Fi driver; while the link is stuck
|
||||
// the newest picture waits and goes out as soon as it moves again.
|
||||
// - The bitrate: when frames start queueing (the round trip grows) or the
|
||||
// gate has to hold frames back, it drops to a bit under what the link
|
||||
// actually delivered; once things are clear it probes up again slowly, never
|
||||
// above the quality setting's bitrate (the ceiling).
|
||||
// - The tier: as the bitrate falls, fewer frames per second (60, 45, 30), then
|
||||
// a smaller picture (75%, then 50% of the panel's pixels).
|
||||
// See docs/mac-in-headset.md ("Adapting to the network"). Thread-safe.
|
||||
// Thin, serialized binding to the same controller used by the PC host.
|
||||
// Algorithm and bounded state live in desktop/controller.c.
|
||||
import Foundation
|
||||
|
||||
final class RateController {
|
||||
struct Tier: Equatable {
|
||||
let fps: Int
|
||||
let scale: Double // of the picture's long side
|
||||
}
|
||||
|
||||
static let tiers = [Tier(fps: 60, scale: 1), Tier(fps: 45, scale: 1), Tier(fps: 30, scale: 1),
|
||||
Tier(fps: 30, scale: 0.75), Tier(fps: 30, scale: 0.5)]
|
||||
/// A tier is used while the target bitrate is at least this share of the ceiling.
|
||||
static let floors = [0.45, 0.28, 0.16, 0.08, 0]
|
||||
static let enabled = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ADAPT"] != "0"
|
||||
|
||||
let maxFps: Int
|
||||
private let lock = NSLock()
|
||||
private var ceiling = 0 // bits/s at full size and frame rate
|
||||
private(set) var target = 0
|
||||
private(set) var tier = 0
|
||||
private var unacked: [(seq: UInt32, sent: Int64, bytes: Int)] = []
|
||||
/// Round trips (send -> ack arrives here), for the baseline: the lowest
|
||||
/// in the last 10 s is the path without any queue.
|
||||
private var rtts: [(t: Int64, v: Int64)] = []
|
||||
private var acked: [(t: Int64, bytes: Int)] = [] // the last second
|
||||
private var sentLog: [(t: Int64, bytes: Int)] = []
|
||||
private var captures: [Int64] = [] // the last second
|
||||
private var frameBytes = 0 // average recent frame, kept while the gate holds everything back
|
||||
private var held = 0 // frames the gate held back since the last update
|
||||
private var lastSignal = false
|
||||
private var sawAck = false
|
||||
private var lastDecrease: Int64 = 0
|
||||
private var lastIncrease: Int64 = 0
|
||||
private var belowSince: Int64 = 0, aboveSince: Int64 = 0
|
||||
/// What changed, for the timeline: (time, event).
|
||||
private(set) var events: [(Int64, String)] = []
|
||||
|
||||
init(maxFps: Int) { self.maxFps = maxFps }
|
||||
|
||||
private let core: OpaquePointer
|
||||
private var events: [[String: Any]] = []
|
||||
init(maxFps: Int) {
|
||||
self.maxFps = maxFps
|
||||
core = fc_new(Int32(maxFps), Self.enabled ? 1 : 0)!
|
||||
}
|
||||
deinit { fc_free(core) }
|
||||
private func locked<T>(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
|
||||
|
||||
/// The quality setting's bitrate at full size; the first call also starts there.
|
||||
func setCeiling(_ bps: Int) {
|
||||
locked {
|
||||
if target == 0 || target > bps { target = bps }
|
||||
ceiling = bps
|
||||
}
|
||||
}
|
||||
|
||||
var fps: Int { locked { fpsLocked } }
|
||||
private var fpsLocked: Int { min(maxFps, RateController.tiers[tier].fps) }
|
||||
var scale: Double { locked { RateController.tiers[tier].scale } }
|
||||
var baseRtt: Int64 { locked { baseline() } }
|
||||
|
||||
private func baseline() -> Int64 { rtts.map(\.v).min() ?? 0 }
|
||||
|
||||
/// How late a frame may be before the gate holds the next one: one frame
|
||||
/// interval, plus room for the jitter this link normally has (1.5 times
|
||||
/// its recent spread), so ordinary Wi-Fi jitter doesn't cost frames but a
|
||||
/// real queue does. Updated in update().
|
||||
private var slack: Int64 = 40_000
|
||||
|
||||
/// Whether a frame may be sent now without queueing behind earlier ones.
|
||||
/// `counts`: a held frame is a sign of congestion (not when merely
|
||||
/// re-checking whether a held frame can go yet).
|
||||
func maySend(now: Int64, counts: Bool = true) -> Bool {
|
||||
locked {
|
||||
guard RateController.enabled, sawAck else { return true } // not heard from the viewer yet
|
||||
// Unacknowledged for 2 s: gone with a reconnection, not in a queue.
|
||||
unacked.removeAll { now - $0.sent > 2_000_000 }
|
||||
guard let oldest = unacked.first else { return true }
|
||||
// Age is what bounds latency. The count only stops a burst, and it
|
||||
// allows a full round trip of frames, so a long but clear path
|
||||
// (100 ms away) still gets every frame.
|
||||
let interval = Int64(1_000_000 / max(1, fpsLocked))
|
||||
let window = max(3, Int((baseline() + slack) / interval) + 1)
|
||||
if unacked.count < window, now - oldest.sent <= baseline() + slack { return true }
|
||||
if counts { held += 1 }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture was captured (sent or not): with the frame sizes, what this
|
||||
/// stream would send if the link allowed.
|
||||
func captured(at t: Int64) {
|
||||
locked {
|
||||
captures.append(t)
|
||||
if captures.count > 256 { captures.removeFirst(captures.count - 256) }
|
||||
}
|
||||
}
|
||||
|
||||
func sent(seq: UInt32, bytes: Int, at t: Int64) {
|
||||
locked {
|
||||
// Bounded even if the viewer never acknowledges (an old viewer, or
|
||||
// the controller is off).
|
||||
unacked.append((seq, t, bytes))
|
||||
if unacked.count > 512 { unacked.removeFirst(unacked.count - 512) }
|
||||
sentLog.append((t, bytes))
|
||||
if sentLog.count > 1024 { sentLog.removeFirst(sentLog.count - 1024) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer has frame `seq`. Returns true if that may let a held frame go.
|
||||
func acked(seq: UInt32, at now: Int64) -> Bool {
|
||||
locked {
|
||||
sawAck = true
|
||||
guard let i = unacked.firstIndex(where: { $0.seq == seq }) else { return false }
|
||||
let f = unacked[i]
|
||||
unacked.removeSubrange(0...i) // TCP delivers in order: earlier ones arrived too
|
||||
rtts.append((now, now - f.sent))
|
||||
acked.append((now, f.bytes))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every 100 ms. Returns the new bitrate target, or nil if the
|
||||
/// controller is off.
|
||||
private func value(_ field: Int32) -> Int { Int(fc_value(core, field)) }
|
||||
var target: Int { locked { value(0) } }
|
||||
var tier: Int { locked { value(2) } }
|
||||
var fps: Int { locked { value(3) } }
|
||||
var scale: Double { locked { Double(value(4)) / 100 } }
|
||||
var baseRtt: Int64 { locked { fc_value(core, 5) } }
|
||||
func setCeiling(_ bps: Int) { locked { fc_ceiling(core, Int32(bps)) } }
|
||||
func maySend(now: Int64, counts: Bool = true) -> Bool { locked { fc_gate(core, now, counts ? 1 : 0) != 0 } }
|
||||
func captured(at t: Int64) { locked { fc_capture(core, t) } }
|
||||
func sent(seq: UInt32, bytes: Int, at t: Int64) { locked { fc_sent(core, seq, Int32(bytes), t) } }
|
||||
func acked(seq: UInt32, at t: Int64) -> Bool { locked { fc_ack(core, seq, t) != 0 } }
|
||||
func update(now: Int64) -> Int? {
|
||||
locked {
|
||||
rtts.removeAll { now - $0.t > 10_000_000 }
|
||||
acked.removeAll { now - $0.t > 500_000 }
|
||||
sentLog.removeAll { now - $0.t > 500_000 }
|
||||
unacked.removeAll { now - $0.sent > 2_000_000 }
|
||||
captures.removeAll { now - $0 > 1_000_000 }
|
||||
guard RateController.enabled, ceiling > 0 else { return nil }
|
||||
let base = baseline()
|
||||
let spread = rtts.filter { now - $0.t < 2_000_000 }.map(\.v).sorted()
|
||||
let jitter = spread.isEmpty ? 0 : spread[spread.count * 9 / 10] - base
|
||||
let interval = Int64(1_000_000 / max(1, fpsLocked))
|
||||
slack = interval + min(max(jitter * 3 / 2, 25_000), 80_000)
|
||||
let recent = rtts.filter { now - $0.t < 300_000 }.map(\.v).sorted()
|
||||
let queueing = recent.isEmpty ? 0 : recent[recent.count / 2] - base
|
||||
let oldestAge = unacked.first.map { now - $0.sent } ?? 0
|
||||
let stuck = oldestAge > base + 100_000
|
||||
let delivered = acked.reduce(0) { $0 + $1.bytes } * 16 // bits/s over the last half second
|
||||
let sending = sentLog.reduce(0) { $0 + $1.bytes } * 16
|
||||
// Demand: captures per second (up to the tier's rate) times the
|
||||
// average frame. A test card or a mostly still window wants far
|
||||
// less than its budget; when the link hiccups, cutting its bitrate
|
||||
// can't help, and it would only look link-limited afterwards.
|
||||
if !sentLog.isEmpty { frameBytes = sentLog.reduce(0) { $0 + $1.bytes } / sentLog.count }
|
||||
let demand = min(captures.count, fpsLocked) * frameBytes * 8
|
||||
// Delay alone isn't our queue: Wi-Fi jitters by itself. It only
|
||||
// counts while this stream uses a good part of its budget (so its
|
||||
// own data could be what's queueing). Frames the gate had to hold,
|
||||
// or one stuck in flight, show demand the link isn't carrying
|
||||
// whatever was sent (the gate itself keeps what's sent low).
|
||||
let busy = sending >= target / 2
|
||||
// Twice in a row (200 ms), so one late ack doesn't count.
|
||||
let signal = (busy && queueing > 40_000) || held >= 3 || stuck
|
||||
let congested = signal && lastSignal
|
||||
lastSignal = signal
|
||||
let heldNow = held
|
||||
held = 0
|
||||
let floorBps = 300_000
|
||||
if congested, now - lastDecrease > 300_000 {
|
||||
// Down to a bit under what got through: at least a fifth off, at
|
||||
// most half (a stall delivers nothing, but the link is still there).
|
||||
let measured = Int(Double(delivered) * 0.9)
|
||||
var next = max(floorBps, min(target * 4 / 5, max(measured, target / 2)))
|
||||
// App-limited (it wants about half its budget or less): never
|
||||
// below twice what it wants, however many cuts in a row. The
|
||||
// extra quarter is hysteresis, so frame sizes wobbling at the
|
||||
// floor don't switch the protection off.
|
||||
if demand > 0, demand * 2 <= target * 5 / 4 { next = max(next, min(target, demand * 2)) }
|
||||
target = next
|
||||
lastDecrease = now
|
||||
events.append((now, "down to \(target / 1000) kbit/s: queue \(queueing / 1000) ms, held \(heldNow), "
|
||||
+ "oldest \(oldestAge / 1000) ms, base \(base / 1000) ms, sent \(sending / 1000) got \(delivered / 1000) "
|
||||
+ "wants \(demand / 1000)"))
|
||||
} else if !congested, now - lastDecrease > 1_000_000, now - lastIncrease > 250_000, target < ceiling,
|
||||
sending > target * 6 / 10 || now - lastDecrease > 3_000_000 {
|
||||
// Clear for a second and using what it has: probe up.
|
||||
target = min(ceiling, Int(Double(target) * 1.1) + 50_000)
|
||||
lastIncrease = now
|
||||
let old = value(0), tier = value(2)
|
||||
let result = Int(fc_update(core, now))
|
||||
if value(0) < old || value(2) != tier {
|
||||
events.append(["t": now, "e": "target \(value(0)) bit/s; tier \(value(2))"])
|
||||
if events.count > 200 { events.removeFirst() }
|
||||
}
|
||||
// Fewer frames or pixels only help a stream that fills its budget;
|
||||
// a small one (a still window, a test card) keeps its tier.
|
||||
retier(now: now, linkLimited: sending >= target * 7 / 10)
|
||||
if events.count > 200 { events.removeFirst(events.count - 200) }
|
||||
return target
|
||||
return result == 0 ? nil : result
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps down quickly, straight to the tier the bitrate supports, and back
|
||||
/// up one tier at a time only when there's clearly room (hysteresis).
|
||||
private func retier(now: Int64, linkLimited: Bool) {
|
||||
let share = Double(target) / Double(max(ceiling, 1))
|
||||
if tier < RateController.tiers.count - 1, share < RateController.floors[tier], linkLimited {
|
||||
if belowSince == 0 { belowSince = now }
|
||||
if now - belowSince > 500_000 {
|
||||
tier = RateController.floors.firstIndex { share >= $0 } ?? RateController.tiers.count - 1
|
||||
belowSince = 0
|
||||
events.append((now, "tier \(tier)"))
|
||||
}
|
||||
} else {
|
||||
belowSince = 0
|
||||
}
|
||||
if tier > 0, share > RateController.floors[tier - 1] * 1.25 {
|
||||
if aboveSince == 0 { aboveSince = now }
|
||||
if now - aboveSince > 2_000_000 {
|
||||
tier -= 1
|
||||
aboveSince = 0
|
||||
events.append((now, "tier \(tier)"))
|
||||
}
|
||||
} else {
|
||||
aboveSince = 0
|
||||
}
|
||||
}
|
||||
|
||||
func state() -> [String: Any] {
|
||||
locked {
|
||||
["target": target, "ceiling": ceiling, "tier": tier, "fps": min(maxFps, RateController.tiers[tier].fps),
|
||||
"scale": RateController.tiers[tier].scale, "baseRtt": Double(baseline()) / 1000,
|
||||
"inFlight": unacked.count, "slack": Double(slack) / 1000, "adapt": RateController.enabled]
|
||||
}
|
||||
locked { ["target": value(0), "ceiling": value(1), "tier": value(2), "fps": value(3),
|
||||
"scale": Double(value(4)) / 100, "baseRtt": Double(value(5)) / 1000,
|
||||
"inFlight": value(6), "slack": Double(value(7)) / 1000, "adapt": Self.enabled] }
|
||||
}
|
||||
|
||||
func eventList() -> [[String: Any]] { locked { events.map { ["t": $0.0, "e": $0.1] } } }
|
||||
func eventList() -> [[String: Any]] { locked { events } }
|
||||
}
|
||||
@@ -5,7 +5,10 @@ set -eu
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=${1:-$here/../bin/frame-mac-view}
|
||||
mkdir -p "$(dirname "$out")"
|
||||
obj=$(mktemp /tmp/frame-controller.XXXXXX)
|
||||
trap 'rm -f "$obj"' EXIT
|
||||
xcrun clang -O2 -target arm64-apple-macos14.0 -c "$here/../../desktop/controller.c" -o "$obj"
|
||||
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
|
||||
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
|
||||
-o "$out" "$here"/Sources/*.swift
|
||||
-o "$out" "$here"/Sources/*.swift "$obj"
|
||||
echo "built $out"
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
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.
|
||||
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 -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
+55
-13
@@ -55,6 +55,7 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_macview # noqa: E402
|
||||
import frame_pcview # noqa: E402
|
||||
|
||||
RESULTS = ROOT / "bench" / "results"
|
||||
PAGES = ROOT / "bench" / "pages"
|
||||
@@ -254,7 +255,10 @@ class LabView(frame_macview.MacView):
|
||||
|
||||
def ssh_runner(base):
|
||||
def run(remote, stdin=None, timeout=30):
|
||||
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
|
||||
capture_output=True, timeout=timeout)
|
||||
r.stdout = r.stdout.decode("utf-8", errors="replace")
|
||||
r.stderr = r.stderr.decode("utf-8", errors="replace")
|
||||
if r.returncode:
|
||||
e = RuntimeError((r.stderr or r.stdout).strip())
|
||||
e.stdout = r.stdout
|
||||
@@ -381,7 +385,11 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if args.browser_flag is not None:
|
||||
mv.browser_flags = [f for f in args.browser_flag if f]
|
||||
chrome = None
|
||||
src = "test"
|
||||
src = args.source or "test"
|
||||
cpu = None
|
||||
if args.pc:
|
||||
mv.host = "windows" if sys.platform == "win32" else "linux"
|
||||
mv.viewer_profile = "pc-view"
|
||||
try:
|
||||
if scenario in ("scroll", "type"):
|
||||
chrome = chrome_window(f"{scenario}.html")
|
||||
@@ -405,9 +413,10 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
since = max([f["s"] for f in first["frames"]] or [0])
|
||||
captured_before = first["captured"]
|
||||
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
if not args.pc:
|
||||
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
|
||||
capture_output=True, text=True).stdout.split()[0])
|
||||
cpu = CpuSampler(frame_ssh, agent_pid)
|
||||
if relay:
|
||||
relay.begin()
|
||||
expected = 0 # input events the harness asked the viewer for
|
||||
@@ -443,7 +452,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
if not streams:
|
||||
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
|
||||
data = streams[0]
|
||||
cpu.stop()
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
if args.raw:
|
||||
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
|
||||
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
|
||||
@@ -452,7 +462,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
for e in data.get("events", []) if e["t"] >= start_mac_us]
|
||||
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
|
||||
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
|
||||
result["cpu"] = cpu.summary()
|
||||
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
|
||||
result["viewer"] = data["summary"].get("decoder", "")
|
||||
result["show_s"] = show_s
|
||||
result["panel"] = shown.get("panel")
|
||||
@@ -460,6 +470,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
|
||||
result["route"] = mv.route
|
||||
return result
|
||||
finally:
|
||||
if cpu:
|
||||
cpu.stop()
|
||||
try:
|
||||
mv.stop(src)
|
||||
except Exception: # noqa: BLE001 - best effort
|
||||
@@ -670,6 +682,8 @@ def fmt(v):
|
||||
|
||||
def add_run_args(r):
|
||||
r.add_argument("--scenario", default="test,scroll,type")
|
||||
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
|
||||
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
|
||||
r.add_argument("--duration", type=float, default=20)
|
||||
r.add_argument("--warmup", type=float, default=3)
|
||||
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
|
||||
@@ -717,7 +731,7 @@ def frame_ssh_for(args):
|
||||
|
||||
|
||||
def run_suite(args, frame_ssh, quiet=False):
|
||||
agent_port, token = lab_agent()
|
||||
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
|
||||
results = []
|
||||
for sc in args.scenario.split(","):
|
||||
if not quiet:
|
||||
@@ -744,11 +758,12 @@ def document(args, frame_ssh, results, **extra):
|
||||
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
|
||||
return {
|
||||
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
|
||||
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
|
||||
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
|
||||
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
|
||||
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
|
||||
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
|
||||
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
|
||||
"scenarios": results, **extra,
|
||||
}
|
||||
|
||||
@@ -819,19 +834,46 @@ def main():
|
||||
print("\nworse:\n " + "\n ".join(regs))
|
||||
sys.exit(1 if regs else 0)
|
||||
|
||||
lab_agent()
|
||||
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
|
||||
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
|
||||
if args.pc and args.scenario == "capture" and not args.source:
|
||||
p.error("capture needs --source window:ID, display:ID, or choose")
|
||||
if not args.pc:
|
||||
lab_agent()
|
||||
frame_ssh = frame_ssh_for(args)
|
||||
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
|
||||
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
|
||||
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
|
||||
args.pc_view = None
|
||||
try:
|
||||
if args.pc:
|
||||
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
|
||||
args.pc_view.ensure_agent()
|
||||
if args.source == "choose":
|
||||
args.pc_view.call("/permissions", method="POST")
|
||||
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
|
||||
deadline = time.monotonic()+125
|
||||
while time.monotonic() < deadline:
|
||||
state = args.pc_view.call("/status")
|
||||
if not state.get("selecting"):
|
||||
sources = args.pc_view.call("/windows")["windows"]
|
||||
if not sources:
|
||||
raise SystemExit(state.get("selectionError") or "Nothing was shared")
|
||||
args.source = sources[-1]["src"]
|
||||
break
|
||||
time.sleep(.5)
|
||||
else:
|
||||
raise SystemExit("Sharing dialog timed out")
|
||||
if args.cmd == "ab":
|
||||
arm_runs, table = ab(args, frame_ssh)
|
||||
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
|
||||
return
|
||||
results = run_suite(args, frame_ssh)
|
||||
finally:
|
||||
awake.terminate()
|
||||
if args.pc_view:
|
||||
args.pc_view.shutdown()
|
||||
if awake:
|
||||
awake.terminate()
|
||||
doc = document(args, frame_ssh, results)
|
||||
write(doc, args)
|
||||
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
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,87 @@
|
||||
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
|
||||
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
|
||||
throwaway container and keeps its real home, which sandbox would move."""
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,472 @@
|
||||
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const cc = require('../ui/control-center.js');
|
||||
|
||||
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||||
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||||
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||||
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||||
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||||
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||||
const page = element('controlCenter');
|
||||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||
return {document:{getElementById:element},element,cards,buttons};
|
||||
}
|
||||
function mounted(options = {}) {
|
||||
const f = fixture(), calls = [];
|
||||
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||||
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||||
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||||
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||||
}
|
||||
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||||
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||
// Execute the actual shared refresh/deduplication, host callback and mount
|
||||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||
// rendering helpers, timers and transport are stubbed.
|
||||
function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||||
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||||
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||||
watchConnection(){context.watched=true;}};
|
||||
vm.createContext(context);
|
||||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||
if(productionApi) {
|
||||
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||||
// rejection classification and the real switch/reset notifications run.
|
||||
context.fetch=(path,opts)=>{
|
||||
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||||
}
|
||||
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||||
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||
}
|
||||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const old=f.refresh(),body=deferred();
|
||||
assert.equal(f.requests[0].path,'/api/status');
|
||||
if(rejection==='body') {
|
||||
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||||
}
|
||||
f.switchHeadset();
|
||||
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||||
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||||
f.requests[1].resolve(response(next));await flush();
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||||
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||||
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const assertB=()=>{
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||||
};
|
||||
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||||
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||||
// An ordinary failure on B must still invalidate the current sample.
|
||||
const failed=f.refresh();
|
||||
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||||
else f.requests[2].reject(Error('B transport failed'));
|
||||
await failed;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
});
|
||||
}
|
||||
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||||
const result=pending.then(value=>({value}),error=>({error}));
|
||||
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||||
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||||
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||||
const settled=await result;
|
||||
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||||
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||||
}
|
||||
});
|
||||
test('search finds settings by aliases, all words, category and guidance', () => {
|
||||
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||||
assert.ok(find('DPI').includes('display'));
|
||||
assert.ok(find('sleep inactivity').includes('power'));
|
||||
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||||
assert.ok(find('font','apps').includes('display'));
|
||||
assert.deepEqual(find('font','vr'),[]);
|
||||
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||||
assert.ok(find(' ').length > 20);
|
||||
});
|
||||
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||||
const m = cc.metrics(sample());
|
||||
assert.equal(m.battery,'99%');
|
||||
assert.equal(m.power,'0 W');
|
||||
assert.match(m.batteryDetail,/not charging/);
|
||||
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||||
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||||
assert.equal(m.thermal,'No hot / critical trip reported');
|
||||
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||||
await assert.rejects(controller.audio(change),/valid mute state/);
|
||||
}
|
||||
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||||
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||||
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||||
const pending=controller.audio({level:.7});
|
||||
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||||
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||||
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||||
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||||
});
|
||||
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||||
let calls=0;
|
||||
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||||
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||||
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||||
assert.equal(controller.busy,false);
|
||||
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||||
});
|
||||
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.ui.status(sample());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccVolume').value,40);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||||
assert.equal(f.element('ccVolume').value,'65');
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||||
assert.equal(f.refreshes,1);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||||
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
});
|
||||
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.context.online=false;f.ui.connection(false);
|
||||
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccVolume').disabled,true);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||||
f.ui.unavailable(Error('Local server is unavailable'));
|
||||
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||||
f.context.online=true;f.ui.status(sample());
|
||||
assert.equal(f.element('ccVolume').disabled,false);
|
||||
});
|
||||
test('mounted audio failure is visible and never reports success', async t => {
|
||||
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.refreshes,0);
|
||||
});
|
||||
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||||
let expected=.65, mode='confirmed';
|
||||
const f=mounted({refresh:async()=>{
|
||||
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||||
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||||
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||||
mode='offline';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||||
let complete;
|
||||
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
const pending=f.element('ccMute').onclick();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||||
complete({message:'Old headset muted'});await pending;
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
assert.equal(f.refreshes,0);
|
||||
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||||
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccMute').disabled,false);
|
||||
});
|
||||
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||||
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const pending=f.element('ccApplyVolume').onclick();
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||||
await pending;
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||||
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
}
|
||||
});
|
||||
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||||
for(const early of [true,false]) {
|
||||
const f=sharedPage();let ui;
|
||||
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||||
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||||
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||||
assert.equal(f.context.watched,true);
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
}
|
||||
});
|
||||
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||||
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||||
f.requests[0].resolve(sample());await prior;await flush();
|
||||
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||||
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||||
for(const failedReadback of [false,true]) {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||||
assert.equal(f.requests.length,2);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||||
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||||
await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||||
}
|
||||
});
|
||||
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.installFlatpak(); // actual production render(state), with job held pending
|
||||
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||||
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||||
f.requests[2].resolve(next);await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'98%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
});
|
||||
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||||
for(const priorSuccess of [true,false]) {
|
||||
const f=sharedPage();
|
||||
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||||
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.requests.length,calls);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.requests.at(-1).resolve(sample());await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
}
|
||||
});
|
||||
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||||
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||||
f.ui.status(reading,Date.now()+1000);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||||
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||||
f.requests[0].resolve(reading);await prior;
|
||||
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||||
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
});
|
||||
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||||
now+=91000;f.ui.status(reading);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||||
f.ui.status({...sample(),time});
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||||
}
|
||||
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||||
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
}
|
||||
});
|
||||
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||||
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||||
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||||
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,65);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||||
});
|
||||
test('live samples expire even when the status poll is paused', t => {
|
||||
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
now+=91000;
|
||||
// A connection update must also re-check freshness before enabling a control.
|
||||
f.ui.connection(true);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||||
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||||
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||||
assert.equal(f.element('ccEmpty').hidden,false);
|
||||
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||||
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||||
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||
});
|
||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||
assert.equal(activePage,expected[id],href);
|
||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
|
||||
});
|
||||
+27
-4
@@ -10,6 +10,7 @@ import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,7 +23,22 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
||||
|
||||
ENABLED = os.environ.get('FRAME_E2E') == '1'
|
||||
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
|
||||
# the container runtime's marker file): these tests drive ui/server.py with the real
|
||||
# home, ~/.ssh and cache, which is only safe in a throwaway container.
|
||||
REQUESTED = os.environ.get('FRAME_E2E') == '1'
|
||||
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
|
||||
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
|
||||
ENABLED = REQUESTED and IN_CONTAINER
|
||||
if ENABLED:
|
||||
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
|
||||
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
|
||||
# database, not $HOME. (These tests used to get this through test_frame_apk.)
|
||||
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
|
||||
atexit.register(shutil.rmtree, _data, ignore_errors=True)
|
||||
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
|
||||
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
|
||||
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
|
||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
||||
|
||||
|
||||
def require():
|
||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
||||
if not ENABLED:
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
"""Skips (the module, at module level; the class, in setUpClass) unless this is
|
||||
scripts/e2e.sh's container with the fake Frame up."""
|
||||
if ENABLED:
|
||||
return
|
||||
if REQUESTED:
|
||||
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
|
||||
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
|
||||
|
||||
# ---- the fake Frame's control port --------------------------------------------
|
||||
@@ -97,6 +118,7 @@ class Server:
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
require() # never a server on this computer's real data
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
require()
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -6,6 +6,8 @@ import sys
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
import harness
|
||||
from harness import HOME, exists, ok, state, upload, wait_for
|
||||
from test_frame_apk import apk, manifest, resources
|
||||
from apk_fixtures import apk, manifest, resources
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for Android's adb, for tests/test_quest.py: just enough of `devices -l`,
|
||||
`shell`, and `pull` for frame_quest.py, run as a real subprocess.
|
||||
|
||||
$FAKEADB_STATE is a JSON file:
|
||||
devices [{"serial", "state", "extra" (the rest of the `devices -l` line), "props" {getprop name: value}}]
|
||||
root a folder standing in for each device's file system (remote /x/y is root/x/y)
|
||||
packages {package: [APK paths, as `pm path` prints them]}
|
||||
fail_pull a remote path whose pull fails
|
||||
Every call is appended to $FAKEADB_LOG as a JSON line. Shell commands are the simple
|
||||
`; `-separated ones frame_quest sends, each answered here: no real shell runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ['FAKEADB_LOG'], 'a') as f:
|
||||
f.write(json.dumps(args) + '\n')
|
||||
with open(os.environ['FAKEADB_STATE']) as f:
|
||||
state = json.load(f)
|
||||
root = state.get('root', '')
|
||||
serial = None
|
||||
if args[:1] == ['-s']:
|
||||
serial, args = args[1], args[2:]
|
||||
|
||||
|
||||
def fail(message, code=1):
|
||||
sys.stderr.write(message + '\n')
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def local(remote):
|
||||
return os.path.join(root, remote.lstrip('/'))
|
||||
|
||||
|
||||
def size_kb(path):
|
||||
total = 0
|
||||
for dirpath, _, files in os.walk(path):
|
||||
total += sum(os.path.getsize(os.path.join(dirpath, n)) for n in files)
|
||||
return max(4, total // 1024)
|
||||
|
||||
|
||||
if args == ['devices', '-l']:
|
||||
print('List of devices attached')
|
||||
for d in state.get('devices', []):
|
||||
print(f"{d['serial']:<22} {d['state']} {d.get('extra', '')}".rstrip())
|
||||
sys.exit(0)
|
||||
|
||||
device = next((d for d in state.get('devices', []) if d['serial'] == serial), None)
|
||||
if serial is None or device is None:
|
||||
fail(f"adb: device '{serial}' not found")
|
||||
if device['state'] == 'unauthorized':
|
||||
fail('adb: device unauthorized.\nThis adb server\'s $ADB_VENDOR_KEYS is not set')
|
||||
if device['state'] != 'device':
|
||||
fail(f"adb: device {device['state']}")
|
||||
|
||||
if args[:1] == ['shell']:
|
||||
out = []
|
||||
for command in ' '.join(args[1:]).split('; '):
|
||||
command = command.replace(' 2>/dev/null', '').strip()
|
||||
words = command.split()
|
||||
if not words or command == 'true':
|
||||
continue
|
||||
if words[0] == 'getprop':
|
||||
out.append(device.get('props', {}).get(words[1], ''))
|
||||
elif words[0] == 'echo':
|
||||
out.append(' '.join(words[1:]))
|
||||
elif command == 'pm list packages -3':
|
||||
out += ['package:' + p for p in state.get('packages', {})]
|
||||
elif words[:2] == ['pm', 'path']:
|
||||
out += ['package:' + p for p in state.get('packages', {}).get(words[2], [])]
|
||||
elif words[:2] == ['du', '-sk']:
|
||||
if os.path.isdir(local(words[2])):
|
||||
out.append(f'{size_kb(local(words[2]))}\t{words[2]}')
|
||||
elif words[:3] == ['stat', '-c', '%s']:
|
||||
out += [str(os.path.getsize(local(p.strip("'")))) for p in words[3:]]
|
||||
else:
|
||||
fail(f'/system/bin/sh: {words[0]}: inaccessible or not found', 127)
|
||||
print('\n'.join(out))
|
||||
sys.exit(0)
|
||||
|
||||
if args[:1] == ['pull'] and len(args) == 3:
|
||||
remote, dest = args[1], args[2]
|
||||
if remote == state.get('fail_pull'):
|
||||
fail(f"adb: error: failed to copy '{remote}' to '{dest}': remote Connection reset")
|
||||
src = local(remote)
|
||||
if not os.path.exists(src):
|
||||
fail(f"adb: error: failed to stat remote object '{remote}': No such file or directory")
|
||||
if os.path.isdir(dest):
|
||||
dest = os.path.join(dest, os.path.basename(remote.rstrip('/')))
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dest)
|
||||
else:
|
||||
shutil.copyfile(src, dest)
|
||||
print(f'{remote}: 1 file pulled.')
|
||||
sys.exit(0)
|
||||
|
||||
fail('adb: unknown command ' + ' '.join(args))
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
FAKEFRAME_CTL: http://fakeframe:9999
|
||||
FAKEFRAME_HOST: fakeframe
|
||||
FRAME_E2E: "1"
|
||||
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||
interval: 1s
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The Meta library's rows, run in node against the real functions from ui/index.html.
|
||||
// Found in a live test with a real account (2026-10-14): sizes were cut off behind the
|
||||
// package name, a required data file Frame Control can't place was not mentioned, and
|
||||
// apps and environments were counted as "games".
|
||||
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 els = { metaCount: { textContent: "" }, metaApps: { innerHTML: "" } };
|
||||
const metaLib = { connected: true, results: new Map(), apps: [
|
||||
{ id: "1", title: "The Climb", package: "com.crytek.climb", version: "666.0", version_code: 666, size: 30639455,
|
||||
data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
|
||||
{ id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
|
||||
] };
|
||||
const render = new Function("$", "metaLib", "androidApps", "installing", "queued",
|
||||
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
|
||||
render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
|
||||
const html = els.metaApps.innerHTML;
|
||||
if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
|
||||
const climb = html.split('class="item"')[1];
|
||||
if (!/<div class="s" title="[^"]*">29 MB · v666\.0 \(666\) · com\.crytek\.climb</.test(climb)) fail("size not first: " + climb);
|
||||
if (!/Not installed: 1 required data file \(main\.com\.crytek\.climb\.obb\)/.test(climb)) fail("skipped data not shown: " + climb);
|
||||
const saber = html.split('class="item"')[2];
|
||||
if (!/<div class="s v-no"[^>]*>Meta's API said HTTP 500</.test(saber)) fail("problem not on its own line: " + saber);
|
||||
if (/Not installed/.test(saber)) fail("warning without skipped files");
|
||||
// Waiting in the install queue: marked, and not offered again.
|
||||
if (!/<span class="tag">Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
|
||||
if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
|
||||
metaLib.apps = metaLib.apps.slice(0, 1);
|
||||
render(id => els[id], metaLib, [], new Set(), new Set());
|
||||
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
|
||||
console.log("ok");
|
||||
@@ -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,8 +19,8 @@ function grab(name) {
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails"];
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
// A fake page: elements by id. The install panel's elements exist only while
|
||||
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } });
|
||||
return { els, sourceState, calls, ...fns };
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
|
||||
{
|
||||
const p = page(async () => { throw new Error("offline"); });
|
||||
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.calls.length) fail("asked for details of a listing that couldn't render");
|
||||
}
|
||||
@@ -90,4 +92,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.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");
|
||||
+31
-1
@@ -1,12 +1,20 @@
|
||||
"""Imported first by every test module: nothing a test does reaches this person's
|
||||
app data, their telemetry, or the shared compatibility database.
|
||||
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
|
||||
|
||||
Must run before any ui module is imported, since those read these at import time.
|
||||
The variables are set in os.environ, so servers and scripts the tests start
|
||||
inherit them too (they pass {**os.environ, ...}).
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# The environment and real ~/.ssh from before the changes below: test_sandbox
|
||||
# reruns tests from that environment and checks ~/.ssh is left alone.
|
||||
ORIGINAL_ENV = dict(os.environ)
|
||||
REAL_SSH_DIR = Path.home() / ".ssh"
|
||||
|
||||
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
|
||||
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
|
||||
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
|
||||
# into the real ~/.ssh/frame-control-hosts on every run.
|
||||
#
|
||||
# tests/e2e must not import this (directly or through a test_* module): they run in
|
||||
# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
|
||||
# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
|
||||
# need live in sandbox-free modules such as apk_fixtures.py.
|
||||
HOME = os.path.join(_dir, "home")
|
||||
SSH_DIR = os.path.join(HOME, ".ssh")
|
||||
os.makedirs(SSH_DIR, mode=0o700)
|
||||
os.environ["HOME"] = HOME
|
||||
os.environ["USERPROFILE"] = HOME # Path.home() on Windows
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
|
||||
# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
|
||||
# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
|
||||
for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
|
||||
("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
|
||||
("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
|
||||
os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -324,7 +325,7 @@ class EndpointTests(SettingsTest):
|
||||
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
|
||||
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
|
||||
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
|
||||
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
|
||||
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': 'remove', 'source': 'fdroid'})[0], 200)
|
||||
mod.remove_repo.assert_called_once_with(source_id='fdroid')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
|
||||
self.assertIsNone(status.activity_level())
|
||||
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
|
||||
self.assertEqual(status.activity_level(), 3)
|
||||
|
||||
|
||||
class DesktopBundleTests(unittest.TestCase):
|
||||
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
|
||||
def test_sources_are_sent_once_per_version(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
|
||||
here.mkdir()
|
||||
home.mkdir()
|
||||
for name in server.COMFORT_FILES:
|
||||
(here / name).write_text('# ' + name + '\n')
|
||||
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
|
||||
sent = []
|
||||
|
||||
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
|
||||
sent.append(stdin)
|
||||
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
|
||||
env={**os.environ, 'HOME': str(home)}).stdout
|
||||
|
||||
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
|
||||
first = server.comfort({'action': 'status'})
|
||||
self.assertEqual(json.loads(first['got']), {'action': 'status'})
|
||||
self.assertEqual(len(sent), 2) # the check, then the sources
|
||||
self.assertNotIn('# frame_steam.py', sent[0])
|
||||
self.assertIn('# frame_steam.py', sent[1])
|
||||
self.assertEqual(server.comfort({'action': 'status'}), first)
|
||||
self.assertEqual(len(sent), 3)
|
||||
self.assertNotIn('# frame_steam.py', sent[2])
|
||||
cached, = (home / '.cache/frame-control/comfort').iterdir()
|
||||
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
|
||||
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
|
||||
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
|
||||
# A copy cut short is completed; a changed helper is a new version.
|
||||
(cached / 'frame_status.py').unlink()
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 5)
|
||||
self.assertTrue((cached / 'frame_status.py').exists())
|
||||
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 7)
|
||||
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -58,3 +59,74 @@ const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
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)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_poll_waits_while_the_frame_is_unreachable(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
start = page.index('async function pollComfort()')
|
||||
code = page[start:page.index('$("comfortForm").onsubmit', start)]
|
||||
js = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const el = {textContent: 'Checking session…'};
|
||||
const $ = () => el;
|
||||
let online = false, comfortBusy = false, calls = 0, timers = 0;
|
||||
const api = async () => { calls++; return {}; };
|
||||
const renderComfort = () => {};
|
||||
const setTimeout = () => { timers++; };
|
||||
''' + code + r'''
|
||||
(async () => {
|
||||
await pollComfort();
|
||||
assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
|
||||
assert.match(el.textContent, /when the Frame is connected/);
|
||||
assert.equal(timers, 1); // still scheduled, to resume when it's back
|
||||
online = true;
|
||||
await pollComfort();
|
||||
assert.equal(calls, 1);
|
||||
online = null; // a status check that failed for another reason: keep polling
|
||||
await pollComfort();
|
||||
assert.equal(calls, 2);
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Control Center API guards and shared-page behavior, without a real headset."""
|
||||
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server
|
||||
import frame_status
|
||||
|
||||
|
||||
class AudioValidation(unittest.TestCase):
|
||||
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
|
||||
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
|
||||
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
|
||||
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
|
||||
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
|
||||
for body in invalid:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
|
||||
with self.assertRaises(server.Failure) as raised:
|
||||
server.set_volume(body)
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_supported_actions_use_wpctl_with_exact_values(self):
|
||||
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
|
||||
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
|
||||
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
|
||||
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
|
||||
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
|
||||
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
|
||||
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
|
||||
|
||||
def test_device_error_propagates_without_claiming_success(self):
|
||||
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
|
||||
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
|
||||
server.set_volume({'level': .5})
|
||||
|
||||
|
||||
class Coverage(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
|
||||
def test_control_center_behavior(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_shared_assets_ship_to_iphone_and_desktop(self):
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
files = set(bundle.files())
|
||||
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
|
||||
self.assertIn(ROOT / 'ui' / name, files)
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
|
||||
self.assertIn('*.js', ui['filter'])
|
||||
self.assertIn('*.css', ui['filter'])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
|
||||
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
|
||||
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
|
||||
probes['read'].return_value = '120 0'
|
||||
probes['process_names'].return_value = set()
|
||||
result = frame_status.status()
|
||||
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
|
||||
@@ -323,6 +323,20 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
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):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline authenticated repository fixtures; no tests contact a server."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -80,7 +81,7 @@ class Repositories(unittest.TestCase):
|
||||
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]
|
||||
if self.v1 and name == 'entry.jar':
|
||||
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
|
||||
@@ -310,7 +311,7 @@ class Repositories(unittest.TestCase):
|
||||
source = self.add()
|
||||
other = dict(source, id='other-repo')
|
||||
started, release = threading.Event(), threading.Event()
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
if threading.current_thread().name == 'slow':
|
||||
started.set()
|
||||
release.wait(5)
|
||||
@@ -354,7 +355,7 @@ class Repositories(unittest.TestCase):
|
||||
self.files['entry.jar'], _ = entry_jar(50)
|
||||
source = fdroid.add_repo(URL)
|
||||
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
name = threading.current_thread().name
|
||||
if name in jars and url.endswith('entry.jar'):
|
||||
Path(path).write_bytes(jars[name])
|
||||
@@ -400,7 +401,7 @@ class Repositories(unittest.TestCase):
|
||||
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
|
||||
self.v1 = True
|
||||
inner = []
|
||||
def fetch(url, path, maximum):
|
||||
def fetch(url, path, maximum, auth=None):
|
||||
if url.endswith('index-v1.jar') and not inner:
|
||||
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
|
||||
self.v1 = False
|
||||
@@ -470,7 +471,7 @@ class Repositories(unittest.TestCase):
|
||||
source = self.add()
|
||||
self.expire(source)
|
||||
release = threading.Event()
|
||||
def slow(url, path, maximum):
|
||||
def slow(url, path, maximum, auth=None):
|
||||
release.wait(5)
|
||||
self.fetch(url, path, maximum)
|
||||
self.fetch_mock.side_effect = slow
|
||||
@@ -527,7 +528,7 @@ class Repositories(unittest.TestCase):
|
||||
self.expire(source)
|
||||
before = self.fetch_mock.call_count
|
||||
release = __import__('threading').Event()
|
||||
def slow(url, path, maximum):
|
||||
def slow(url, path, maximum, auth=None):
|
||||
release.wait(5)
|
||||
self.fetch(url, path, maximum)
|
||||
self.fetch_mock.side_effect = slow
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline artwork, Steam API and launcher supervision regressions."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -120,7 +121,27 @@ class LauncherTests(unittest.TestCase):
|
||||
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):
|
||||
def setUp(self):
|
||||
wait_for_idle_resolvers()
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -231,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
|
||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||
finally:
|
||||
gate.set()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
||||
time.sleep(0.01)
|
||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
||||
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||
|
||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||
from apk_sources import _images
|
||||
@@ -242,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
for _ in range(6):
|
||||
with self.assertRaises(RuntimeError):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||
for _ in range(4): # every slot came back
|
||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
||||
for _ in range(4):
|
||||
_images._resolvers.release()
|
||||
held = 0
|
||||
try:
|
||||
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||
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):
|
||||
import threading
|
||||
@@ -422,6 +444,19 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
|
||||
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
|
||||
|
||||
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
|
||||
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
|
||||
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
|
||||
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
|
||||
patch.object(android, 'ssh', return_value='') as ssh, \
|
||||
patch.object(android, 'shortcut_tool') as api:
|
||||
with self.assertRaisesRegex(android.FrameError, 'timed out'):
|
||||
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
|
||||
commands = [c.args[0] for c in ssh.call_args_list]
|
||||
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
|
||||
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
|
||||
api.assert_not_called()
|
||||
|
||||
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
|
||||
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
|
||||
patch.object(android, 'stop'), \
|
||||
@@ -485,6 +520,49 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
|
||||
|
||||
|
||||
class CopyAndSshTests(unittest.TestCase):
|
||||
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
|
||||
gets the time it needs."""
|
||||
|
||||
def fake(self, stderr, code=1, stdout=b''):
|
||||
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
|
||||
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
|
||||
f'sys.exit({code})')
|
||||
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
|
||||
|
||||
def test_large_apk_copy_timeout_grows_with_its_size(self):
|
||||
seen = {}
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
|
||||
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
|
||||
android._copy(f.name, 'Applications/Android/x/app.apk.part')
|
||||
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
|
||||
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
|
||||
android._copy(f.name, 'x/launch.sh', timeout=120)
|
||||
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
|
||||
|
||||
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
|
||||
# A message from a Windows code page, or a file name, isn't UTF-8.
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.shutil, 'which', return_value=None), \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
|
||||
android._copy(f.name, 'x/app.apk.part')
|
||||
|
||||
def test_ssh_failure_messages_are_text(self):
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
|
||||
android.ssh('mv a b')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
|
||||
with self.assertRaises(android.FrameError) as caught:
|
||||
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
|
||||
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
|
||||
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
|
||||
|
||||
|
||||
class SteamAPITests(unittest.TestCase):
|
||||
def test_artwork_api_enums_and_safe_serialization(self):
|
||||
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
||||
|
||||
+15
-76
@@ -11,82 +11,7 @@ import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
|
||||
|
||||
|
||||
class ApkInfo(unittest.TestCase):
|
||||
@@ -168,6 +93,20 @@ class ApkInfo(unittest.TestCase):
|
||||
frame_apk.MAX_MANIFEST = limit
|
||||
self.assertLess(peak, 8 * 1024**2)
|
||||
|
||||
def test_damaged_download_is_an_apk_error(self):
|
||||
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
|
||||
# (a 500, and no install report) and frame_android.install reported as 'other'.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
|
||||
data = bytearray(buf.getvalue())
|
||||
at = data.index(b'PK\x03\x04')
|
||||
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
|
||||
for i in range(start, start + 40):
|
||||
data[i] ^= 0x5a
|
||||
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
|
||||
self.read(bytes(data))
|
||||
|
||||
def test_refuses_compression_android_cant_read(self):
|
||||
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -15,7 +16,7 @@ import frame_apk
|
||||
import frame_apk_vr as vr
|
||||
import frame_apk_sign as signing
|
||||
import frame_android
|
||||
from test_frame_apk import pool
|
||||
from apk_fixtures import pool
|
||||
|
||||
|
||||
DEFAULT = 'android.intent.category.DEFAULT'
|
||||
|
||||
@@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def wait(job, timeout=5):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
j = server.job_status(f'id={job}')
|
||||
if j['done']:
|
||||
return j
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f'job {job} never finished')
|
||||
|
||||
|
||||
class InstallQueue(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertEqual(server._queue, [])
|
||||
|
||||
def blocker(self):
|
||||
"""A queued install that runs until released."""
|
||||
go, started = threading.Event(), threading.Event()
|
||||
|
||||
def work():
|
||||
started.set()
|
||||
go.wait(5)
|
||||
return {'message': 'first done'}
|
||||
job = server.queue_job('Install first', work)['job']
|
||||
self.assertTrue(started.wait(5))
|
||||
self.addCleanup(go.set)
|
||||
return job, go
|
||||
|
||||
def test_runs_in_order_one_at_a_time(self):
|
||||
order, running = [], []
|
||||
|
||||
def work(name):
|
||||
def run(report):
|
||||
running.append(name)
|
||||
self.assertEqual(len(running), 1, 'two installs ran at once')
|
||||
report('Copying', 50)
|
||||
time.sleep(0.02)
|
||||
order.append(name)
|
||||
running.remove(name)
|
||||
return {'message': f'{name} done'}
|
||||
return run
|
||||
first, go = self.blocker()
|
||||
started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')]
|
||||
self.assertEqual([s['queued'] for s in started], [1, 2, 3])
|
||||
self.assertIn('queued, 3 before it', started[2]['message'])
|
||||
q = server.install_queue()['jobs']
|
||||
self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c'])
|
||||
self.assertEqual([j['queued'] for j in q], [False, True, True, True])
|
||||
self.assertEqual(q[1]['stage'], 'Queued: 1 install before it')
|
||||
self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it')
|
||||
go.set()
|
||||
self.assertEqual(wait(first)['message'], 'first done')
|
||||
for s, n in zip(started, 'abc'):
|
||||
j = wait(s['job'])
|
||||
self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False))
|
||||
self.assertEqual(order, ['a', 'b', 'c'])
|
||||
self.assertEqual(server.install_queue(), {'jobs': []})
|
||||
|
||||
def test_a_failure_does_not_stop_the_rest(self):
|
||||
def fail():
|
||||
raise server.Failure('no room on the Frame')
|
||||
bad = server.queue_job('Install bad', fail)['job']
|
||||
good = server.queue_job('Install good', lambda: {'message': 'ok'})['job']
|
||||
self.assertEqual(wait(bad)['error'], 'no room on the Frame')
|
||||
self.assertEqual(wait(good)['message'], 'ok')
|
||||
|
||||
def test_a_waiting_install_can_be_removed_but_not_a_running_one(self):
|
||||
ran = []
|
||||
first, go = self.blocker()
|
||||
second = server.queue_job('Install second', lambda: ran.append(2) or {})['job']
|
||||
third = server.queue_job('Install third', lambda: ran.append(3) or {})['job']
|
||||
self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message'])
|
||||
j = server.job_status(f'id={second}')
|
||||
self.assertTrue(j['done'] and j['cancelled'])
|
||||
self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it')
|
||||
for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409),
|
||||
({'action': 'nope', 'job': third}, 400)):
|
||||
with self.assertRaises(server.Failure) as e:
|
||||
server.queue_action(body)
|
||||
self.assertEqual(e.exception.status, status)
|
||||
go.set()
|
||||
wait(third)
|
||||
self.assertEqual(ran, [3])
|
||||
|
||||
def test_runs_only_on_the_headset_it_was_queued_for(self):
|
||||
class Link:
|
||||
device = 'frame-a'
|
||||
|
||||
def active_device(self):
|
||||
return {'id': self.device}
|
||||
link = Link()
|
||||
with patch.object(server, 'LINK', link):
|
||||
first, go = self.blocker()
|
||||
ran = []
|
||||
later = server.queue_job('Install later', lambda: ran.append(1) or {})['job']
|
||||
link.device = 'frame-b'
|
||||
go.set()
|
||||
self.assertIn('switched headsets', wait(later)['error'])
|
||||
self.assertEqual(ran, [])
|
||||
|
||||
def test_installs_from_each_place_are_queued(self):
|
||||
with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q:
|
||||
server.quest({'action': 'import', 'package': 'com.example.game'})
|
||||
server.meta({'action': 'install', 'app': '123'})
|
||||
with patch.object(server.frame_catalog, 'app'):
|
||||
server.android({'action': 'install', 'package': 'com.example.game'})
|
||||
server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'})
|
||||
with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})):
|
||||
server.source_install({'source': 'fdroid', 'id': 'com.example.game'})
|
||||
self.assertEqual(q.call_count, 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Every public sideload entry point reaches the mandatory five-slot writer."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
|
||||
+58
-1
@@ -46,6 +46,17 @@ def explain(msg):
|
||||
return None
|
||||
|
||||
|
||||
class FailureDetail(unittest.TestCase):
|
||||
def test_an_address_that_failed_says_why_in_a_few_words(self):
|
||||
# #87: the banner put each address's detail after the headline, and with the whole
|
||||
# message as the detail it said everything twice ("...may be answering here..").
|
||||
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
|
||||
"SSH identity changed")
|
||||
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
|
||||
"The connection to the Frame dropped. Try again.")
|
||||
self.assertEqual(fl.failure_detail(None), "SSH failed")
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
@@ -200,7 +211,8 @@ class Connecting(unittest.TestCase):
|
||||
rows = {p["host"]: p for p in s["probes"]}
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||
self.assertIn("different SSH identity", rows["::1"]["detail"])
|
||||
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
|
||||
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
|
||||
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-t")
|
||||
@@ -260,6 +272,8 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
|
||||
self.assertIn("SSH key", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["auth"], "denied") # the page offers Set Up Connection
|
||||
self.assertEqual(s["error"]["short"], "This computer's key refused") # the header pill's words (#87)
|
||||
self.assertEqual(s["probes"][0]["detail"], "This computer's key refused")
|
||||
|
||||
def test_pinned_identity_is_checked_strictly(self):
|
||||
d = self.device("localhost")
|
||||
@@ -349,6 +363,7 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
|
||||
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
|
||||
self.assertIn("missing or can't be read", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
|
||||
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
|
||||
self.assertTrue(view["identity_lost"])
|
||||
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
|
||||
@@ -634,6 +649,7 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
|
||||
self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
|
||||
self.assertIn("Devices tab", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["short"], "SSH identity changed")
|
||||
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
|
||||
r = self.command(d)
|
||||
self.assertEqual(r.returncode, 255)
|
||||
@@ -881,6 +897,47 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual(self.routes, [])
|
||||
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):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
|
||||
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def between(start, end):
|
||||
return PAGE[PAGE.index(start):PAGE.index(end)]
|
||||
|
||||
|
||||
SETUP = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
const serverNow = () => 1000;
|
||||
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
|
||||
const ago = () => 'a minute ago';
|
||||
const openTrust = () => {};
|
||||
const window = {};
|
||||
let online = false;
|
||||
const link = {live: true, s: null};
|
||||
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
|
||||
let rendered = 0;
|
||||
const renderDevices = () => { rendered++; };
|
||||
let refuse = false;
|
||||
const act = async (label, fn) => refuse ? undefined : fn();
|
||||
const api = async () => ({ok: true});
|
||||
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
|
||||
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
|
||||
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
|
||||
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
|
||||
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
|
||||
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
|
||||
+ 'If not, a different device may be answering here.'}};
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
|
||||
class LinkUI(unittest.TestCase):
|
||||
def run_page(self, checks):
|
||||
code = (between("function netLabel(s) {", "function renderConnDlg() {")
|
||||
+ between("async function useDevice(id) {", "async function devAction(")
|
||||
+ between("function deviceStatus(d) {", "function renderDevices() {"))
|
||||
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
|
||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
|
||||
# The connection redesign keeps the state and retry countdown in the pill,
|
||||
# with the full failure explanation in its tooltip and details dialog.
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<span>Offline<\/span>/);
|
||||
assert.ok($('conn').title.includes('Something.'));
|
||||
''')
|
||||
|
||||
def test_the_banner_never_doubles_a_full_stop(self):
|
||||
# "…may be answering here.." and "…(Developer Mode must be on).." (#87).
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderBanner();
|
||||
assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
|
||||
link.s = {...changed, probes: [
|
||||
{host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
|
||||
{host: '100.64.0.2', detail: 'No answer'}]};
|
||||
renderBanner();
|
||||
const text = $('offDetail').textContent;
|
||||
assert.ok(!/\.\./.test(text), text);
|
||||
assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
|
||||
assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
|
||||
assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
|
||||
assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
|
||||
''')
|
||||
|
||||
def test_the_devices_list_keeps_the_whole_message_within_reach(self):
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
const [dot, text, full] = deviceStatus({active: true, addresses: []});
|
||||
assert.equal(dot, 'off');
|
||||
assert.equal(text, 'SSH identity changed');
|
||||
assert.equal(full, changed.error.message); // the row's tooltip
|
||||
''')
|
||||
|
||||
def test_using_a_headset_selects_it(self):
|
||||
# After "Use this headset" on Office Frame, the last headset's details stayed on show.
|
||||
self.run_page(r'''
|
||||
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
|
||||
dv.sel = 'd0';
|
||||
await useDevice('d1');
|
||||
assert.equal(dv.sel, 'd1');
|
||||
assert.equal(dv.found, null);
|
||||
assert.equal(dv.editing, null);
|
||||
assert.equal(rendered, 1);
|
||||
// Refused (an install is running): the selection stays, and the header shows the one in use.
|
||||
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
|
||||
dv.sel = 'd0'; refuse = true;
|
||||
await useDevice('d2');
|
||||
assert.equal(dv.sel, 'd0');
|
||||
assert.equal($('devSel').value, 'd0');
|
||||
''')
|
||||
|
||||
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
# Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
|
||||
header = between("<header>", "</header>")
|
||||
end = header[header.index('<div class="hdr-end">'):]
|
||||
for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
|
||||
self.assertIn(el, end)
|
||||
self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
|
||||
self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,6 +7,7 @@ input reaching the source, and closing.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
@@ -83,7 +84,10 @@ class Helpers(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
|
||||
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
|
||||
if sys.platform == "win32" and not Path(bash).exists():
|
||||
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
|
||||
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_show_checks_the_source_before_anything_else(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
|
||||
+1266
File diff suppressed because it is too large.
Load diff
@@ -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,55 @@
|
||||
"""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', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
|
||||
|
||||
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]
|
||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
||||
start = first.index('function showPage(')
|
||||
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 != '$')
|
||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + 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)]
|
||||
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()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Frozen regression trace from the original mac-in-headset Swift controller.
|
||||
The 600-state reference was produced at 1b90c64, before the C extraction:
|
||||
60 seconds with a slow/congested middle, then recovery. No generated behavior
|
||||
from the new implementation is used to calculate the expected digest.
|
||||
"""
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
from frame_pc_capture import Controller
|
||||
|
||||
|
||||
class ControllerTrace(unittest.TestCase):
|
||||
def test_original_mac_congestion_and_recovery(self):
|
||||
cc = shutil.which('cc') or shutil.which('gcc')
|
||||
if not cc:
|
||||
self.skipTest('C compiler not installed; native build jobs cover this trace')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
|
||||
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
|
||||
lib = C.CDLL(str(path))
|
||||
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
|
||||
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
|
||||
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
|
||||
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
|
||||
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
|
||||
for name, (ret, args) in sig.items():
|
||||
fn = getattr(lib, 'fc_'+name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
c = Controller(lib, 60, 8000000)
|
||||
due, states, seq = [], [], 0
|
||||
try:
|
||||
for tick in range(6000):
|
||||
now = 10000000+tick*10000
|
||||
for t, s in due:
|
||||
if t <= now:
|
||||
c.call('ack', s, now)
|
||||
due = [(t, s) for t, s in due if t > now]
|
||||
if tick % 2 == 0:
|
||||
c.call('capture', now)
|
||||
if c.call('gate', now, 1):
|
||||
seq += 1
|
||||
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
|
||||
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
|
||||
if tick % 10 == 0:
|
||||
c.update(now)
|
||||
x = c.state()
|
||||
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
|
||||
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
|
||||
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
|
||||
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
|
||||
finally:
|
||||
c.close()
|
||||
if sys.platform == 'win32':
|
||||
import _ctypes
|
||||
_ctypes.FreeLibrary(lib._handle)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
|
||||
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
|
||||
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
|
||||
successful real-host capture.
|
||||
"""
|
||||
import ctypes
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_pc_agent as agent
|
||||
import frame_pc_capture as capture
|
||||
import frame_pcview
|
||||
import frame_macview
|
||||
from frame_stream_stats import Stats
|
||||
from test_macview import WS
|
||||
|
||||
|
||||
class GrantsTests(unittest.TestCase):
|
||||
def test_source_bound_retry_ack_and_stop(self):
|
||||
g = agent.Grants('master')
|
||||
ticket = g.ticket('window:1')
|
||||
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
|
||||
key = g.redeem('window:1', {'t': ticket})
|
||||
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
|
||||
g.ack(key)
|
||||
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
|
||||
self.assertEqual(key, g.redeem('window:1', {'r': key}))
|
||||
self.assertIsNone(g.redeem('window:2', {'r': key}))
|
||||
g.revoke('window:1')
|
||||
self.assertIsNone(g.redeem('window:1', {'r': key}))
|
||||
|
||||
def test_expiry_and_stop_before_redeem(self):
|
||||
g = agent.Grants('master')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=1):
|
||||
ticket = g.ticket('test')
|
||||
with mock.patch.object(agent.time, 'monotonic', return_value=62):
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
ticket = g.ticket('test')
|
||||
g.revoke()
|
||||
self.assertIsNone(g.redeem('test', {'t': ticket}))
|
||||
|
||||
|
||||
class AdapterTests(unittest.TestCase):
|
||||
def test_windows_uses_wgc_and_media_foundation(self):
|
||||
for kind in ('window', 'display'):
|
||||
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('capture-api=wgc', pipeline)
|
||||
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
|
||||
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
|
||||
self.assertNotIn('drop=true', pipeline)
|
||||
with self.assertRaises(ValueError):
|
||||
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
|
||||
|
||||
def test_linux_uses_only_portal_fd_and_node(self):
|
||||
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
|
||||
self.assertIn('pipewiresrc fd=19 path=47', p)
|
||||
self.assertIn('tune=zerolatency', p)
|
||||
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
|
||||
|
||||
def test_portal_input_clamps_and_releases_held_state(self):
|
||||
native = mock.Mock()
|
||||
native.lib.fc_portal_input.return_value = 1
|
||||
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
|
||||
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
|
||||
inp.handle(dict(t='k', e='down', key='Control'))
|
||||
inp.release()
|
||||
calls = native.lib.fc_portal_input.call_args_list
|
||||
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
|
||||
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
|
||||
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
|
||||
self.assertFalse(inp.buttons or inp.keys)
|
||||
|
||||
def test_windows_release_and_negative_monitor_origin(self):
|
||||
host = mock.Mock()
|
||||
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
|
||||
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
|
||||
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
|
||||
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
|
||||
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
|
||||
inp.release()
|
||||
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
|
||||
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
|
||||
|
||||
def test_refused_windows_focus_never_clicks_the_covering_app(self):
|
||||
host = mock.Mock()
|
||||
host.rect.return_value = (0, 0, 800, 600)
|
||||
host.user.GetForegroundWindow.return_value = 99
|
||||
host.user.SetForegroundWindow.return_value = False
|
||||
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
|
||||
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
|
||||
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
|
||||
host.send.assert_not_called()
|
||||
|
||||
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
|
||||
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
|
||||
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
|
||||
view.ensure_tunnel = mock.Mock()
|
||||
view.remote_port = 47900
|
||||
view.show('window:12')
|
||||
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
|
||||
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
|
||||
self.assertFalse(view.prefer_usb)
|
||||
with self.assertRaises(frame_macview.MacViewError):
|
||||
view.show('separate:12')
|
||||
|
||||
def test_session_end_releases_native_input_only_once(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event = threading.Event()
|
||||
session.ws, session.input = mock.Mock(), mock.Mock()
|
||||
session.released = False
|
||||
session.end()
|
||||
session.end()
|
||||
session.input.release.assert_called_once()
|
||||
self.assertTrue(session.stop_event.is_set())
|
||||
|
||||
def test_idle_source_is_not_a_stall(self):
|
||||
# PipeWire/WGC deliver frames only on damage: a static desktop sends
|
||||
# its first frame and then nothing. That must not end the session.
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.pending = threading.RLock(), {}
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.stats.captured = 1
|
||||
self.assertIsNone(session.stalled(60000000, 0, 0))
|
||||
# A frame that entered the encoder and never came out is a fault.
|
||||
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
|
||||
self.assertIn('encoder', session.stalled(12000000, 0, 0))
|
||||
self.assertIsNone(session.stalled(10000000, 0, 0))
|
||||
# No initial frame at all after the pipeline opened is a fault.
|
||||
session.pending.clear()
|
||||
self.assertIn('capture', session.stalled(12000000, 0, 1))
|
||||
self.assertIsNone(session.stalled(9000000, 0, 1))
|
||||
|
||||
def test_controller_is_inert_after_close(self):
|
||||
lib = mock.Mock()
|
||||
lib.fc_new.return_value = 1234
|
||||
lib.fc_value.return_value = 100
|
||||
controller = capture.Controller(lib, 60, 5000000)
|
||||
controller.close()
|
||||
lib.fc_free.assert_called_once_with(1234)
|
||||
lib.reset_mock()
|
||||
state = controller.state()
|
||||
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
|
||||
self.assertEqual(controller.call('gate', 1, 1), 0)
|
||||
self.assertEqual(controller.update(1), 0)
|
||||
controller.close()
|
||||
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
|
||||
|
||||
def test_library_path_prepend_adds_no_empty_entry(self):
|
||||
env = {}
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
|
||||
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
|
||||
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
|
||||
with mock.patch.dict(os.environ, clear=True):
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
for name in ('PATH', 'LD_LIBRARY_PATH'):
|
||||
parts = env.get(name, 'x').split(os.pathsep)
|
||||
self.assertNotIn('', parts, name)
|
||||
|
||||
def test_malformed_viewer_input_is_dropped_not_fatal(self):
|
||||
session = object.__new__(agent.Session)
|
||||
session.lock, session.input_lock = threading.RLock(), threading.RLock()
|
||||
session.stop_event, session.key_event = threading.Event(), threading.Event()
|
||||
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
|
||||
session.source = dict(src='display:1', w=100, h=100, portal=1)
|
||||
session.input_enabled, session.released = True, False
|
||||
session.native = mock.Mock()
|
||||
session.native.now.return_value = 0
|
||||
session.controller, session.agent = mock.Mock(), mock.Mock()
|
||||
session.stats = Stats(lambda: 0)
|
||||
session.produce = lambda: None
|
||||
lib = mock.Mock()
|
||||
lib.fc_portal_input.return_value = 1
|
||||
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
|
||||
session.ws = mock.Mock()
|
||||
session.ws.receive.side_effect = [
|
||||
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
|
||||
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
|
||||
with self.assertRaises(ConnectionError):
|
||||
session.start()
|
||||
# Every malformed message was skipped; the good move after them still ran.
|
||||
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
|
||||
self.assertAlmostEqual(moves[-1][2], .5*99)
|
||||
self.assertEqual(session.ws.receive.call_count, 7)
|
||||
|
||||
def test_stats_keep_capture_time_and_bound_records(self):
|
||||
stats = Stats(lambda: 10000000)
|
||||
stats.input(dict(t='m', i=1, tv=9999990))
|
||||
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
|
||||
self.assertEqual(f['echo'], 1)
|
||||
stats.report(dict(t='rx', s=f['s'], r=10000010))
|
||||
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
|
||||
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
|
||||
for _ in range(4100):
|
||||
stats.add(cap=10000001)
|
||||
self.assertEqual(len(stats.frames), 4096)
|
||||
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not capture.LIBRARY.exists():
|
||||
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
|
||||
raise AssertionError('CI required native PC libraries but they were not built')
|
||||
raise unittest.SkipTest('native PC libraries not built on this host')
|
||||
cls.token = 'test-' + os.urandom(8).hex()
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
|
||||
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, env=env)
|
||||
line = cls.proc.stdout.readline()
|
||||
if 'listening' not in line:
|
||||
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
|
||||
cls.port = int(line.rsplit(':', 1)[1])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.stdin.close()
|
||||
try:
|
||||
rc = cls.proc.wait(10)
|
||||
except subprocess.TimeoutExpired:
|
||||
cls.proc.kill()
|
||||
raise AssertionError('Native agent did not stop after EOF')
|
||||
error = cls.proc.stderr.read()
|
||||
cls.proc.stdout.close()
|
||||
cls.proc.stderr.close()
|
||||
if rc:
|
||||
raise AssertionError('Native agent exited %s: %s' % (rc, error))
|
||||
|
||||
def request(self, path, method='GET'):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
|
||||
try:
|
||||
conn.request(method, path)
|
||||
response = conn.getresponse()
|
||||
return response.status, response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
self.request('/close?k='+self.token, 'POST')
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
|
||||
def test_bundled_pipewire_context_needs_no_system_modules(self):
|
||||
# No portal request, remote connection or desktop capture: create and
|
||||
# destroy a private client context to test the dynamically loaded SPA
|
||||
# and protocol modules that ldd cannot discover.
|
||||
code = """
|
||||
import ctypes as C, os
|
||||
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
|
||||
for name, ret, args in [
|
||||
('pw_init', None, [C.c_void_p, C.c_void_p]),
|
||||
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
|
||||
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
|
||||
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
|
||||
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
|
||||
('pw_context_destroy', None, [C.c_void_p]),
|
||||
('pw_main_loop_destroy', None, [C.c_void_p])]:
|
||||
fn = getattr(lib, name)
|
||||
fn.restype, fn.argtypes = ret, args
|
||||
lib.pw_init(None, None)
|
||||
loop = lib.pw_main_loop_new(None)
|
||||
assert loop, 'bundled SPA loop support did not load'
|
||||
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
|
||||
assert context, 'bundled PipeWire client modules did not load'
|
||||
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
|
||||
# The exported factory is video.adapt, which needs a live follower node to
|
||||
# instantiate. Check its configured library and exported factory without
|
||||
# pretending a headless context is a consented video stream.
|
||||
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
|
||||
assert plugin_name, 'video.adapt has no bundled library mapping'
|
||||
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
|
||||
class Factory(C.Structure):
|
||||
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
|
||||
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
|
||||
plugin.spa_handle_factory_enum.restype = C.c_int
|
||||
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
|
||||
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
|
||||
names.append(factory.contents.name)
|
||||
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
|
||||
lib.pw_context_destroy(context)
|
||||
lib.pw_main_loop_destroy(loop)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_last_raw_picture_survives_a_closed_gate(self):
|
||||
# A source sends ONE changed frame, then becomes idle. It must reach
|
||||
# the encoder after congestion clears, without needing another update.
|
||||
code = """
|
||||
import ctypes as C, sys, time
|
||||
from frame_pc_capture import Native, GATE, Encoded, pipeline
|
||||
native = Native()
|
||||
start = None
|
||||
closing = False
|
||||
phases = []
|
||||
def gate(stage, pts, capture, arrived):
|
||||
global start
|
||||
phases.append(stage)
|
||||
if closing: return -1
|
||||
if start is None: start = time.monotonic()
|
||||
return int(stage == 1 or time.monotonic()-start >= .15)
|
||||
callback = GATE(gate)
|
||||
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
|
||||
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
|
||||
error = C.create_string_buffer(1024)
|
||||
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
|
||||
assert handle, error.value
|
||||
try:
|
||||
frame = Encoded()
|
||||
result = 0
|
||||
deadline = time.monotonic()+5
|
||||
while not result and time.monotonic() < deadline:
|
||||
result = native.lib.fc_capture_pull(handle, C.byref(frame))
|
||||
assert result == 1, native.lib.fc_capture_error(handle)
|
||||
assert frame.key and frame.size > 17
|
||||
assert phases.count(0) == 1 and 2 in phases, phases
|
||||
finally:
|
||||
closing = True
|
||||
native.lib.fc_capture_close(handle)
|
||||
"""
|
||||
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
|
||||
env['PYTHONPATH'] = str(ROOT / 'ui')
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_auth_and_real_h264_stats(self):
|
||||
self.assertEqual(self.request('/status')[0], 403)
|
||||
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
|
||||
self.assertEqual(status, 200)
|
||||
ticket = json.loads(body)['ticket']
|
||||
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
|
||||
try:
|
||||
self.assertEqual(ws.status, 101)
|
||||
key, got, echo = None, 0, False
|
||||
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
|
||||
deadline = time.monotonic()+10
|
||||
while time.monotonic() < deadline and (got < 5 or not echo):
|
||||
op, data = ws.recv()
|
||||
if op == 1:
|
||||
m = json.loads(data)
|
||||
self.assertNotEqual(m.get('t'), 'error', m)
|
||||
if m.get('t') == 'hello':
|
||||
key = m['r']
|
||||
ws.send_text(json.dumps(dict(t='ack')))
|
||||
elif op == 2:
|
||||
got += 1
|
||||
seq, echoed = struct.unpack('!II', data[9:17])
|
||||
now = struct.unpack('!Q', data[1:9])[0]
|
||||
echo |= echoed == 7
|
||||
self.assertIn(b'\x00\x00\x00\x01', data[17:])
|
||||
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
|
||||
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
|
||||
self.assertGreaterEqual(got, 5)
|
||||
self.assertTrue(echo)
|
||||
time.sleep(.1)
|
||||
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
|
||||
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
|
||||
self.assertIn('controller', stats)
|
||||
used = WS(self.port, '/stream?src=test&t='+ticket)
|
||||
self.assertEqual(used.status, 403)
|
||||
used.close()
|
||||
self.request('/close?src=test&k='+self.token, 'POST')
|
||||
revoked = WS(self.port, '/stream?src=test&r='+key)
|
||||
self.assertEqual(revoked.status, 403)
|
||||
revoked.close()
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1144
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import re
|
||||
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_connect # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
|
||||
|
||||
def inside(path, folder):
|
||||
path, folder = os.path.realpath(path), os.path.realpath(folder)
|
||||
return path == folder or path.startswith(folder + os.sep)
|
||||
|
||||
|
||||
def snapshot(folder):
|
||||
"""{relative path: (size, mtime)} for everything under FOLDER."""
|
||||
out = {}
|
||||
for base, dirs, files in os.walk(folder):
|
||||
for name in dirs + files:
|
||||
p = os.path.join(base, name)
|
||||
try:
|
||||
st = os.lstat(p)
|
||||
except OSError:
|
||||
continue
|
||||
out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns)
|
||||
return out
|
||||
|
||||
|
||||
class Sandbox(unittest.TestCase):
|
||||
"""Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every
|
||||
run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config)."""
|
||||
|
||||
def test_home_and_ssh_dir_are_throwaway(self):
|
||||
tmp = tempfile.gettempdir()
|
||||
self.assertTrue(inside(Path.home(), tmp), Path.home())
|
||||
self.assertTrue(inside(os.path.expanduser("~"), tmp))
|
||||
for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR,
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"], frame_host.cache_dir(), frame_host.data_dir()):
|
||||
self.assertTrue(inside(d, tmp), d)
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
|
||||
def test_cache_folders_on_every_platform_are_throwaway(self):
|
||||
"""cache_dir() reads LOCALAPPDATA on Windows and XDG_CACHE_HOME on Linux, which a
|
||||
moved HOME alone doesn't cover: servers sweep staging folders there."""
|
||||
tmp = tempfile.gettempdir()
|
||||
for platform in ("MAC", "WINDOWS", None):
|
||||
with mock.patch.object(frame_host, "MAC", platform == "MAC"), \
|
||||
mock.patch.object(frame_host, "WINDOWS", platform == "WINDOWS"):
|
||||
self.assertTrue(inside(frame_host.cache_dir("apk-sources"), tmp), platform)
|
||||
for name in ("LOCALAPPDATA", "APPDATA", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
|
||||
self.assertTrue(inside(os.environ[name], tmp), name)
|
||||
|
||||
def test_end_to_end_tests_stay_out_of_it(self):
|
||||
"""tests/e2e keeps the container's real home (OpenSSH reads ~/.ssh from the password
|
||||
database, not $HOME), so nothing there may import sandbox, even through a test_* module."""
|
||||
here = Path(__file__).resolve().parent
|
||||
pattern = re.compile(r"^\s*(import sandbox|from sandbox |import test_|from test_)", re.M)
|
||||
for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]:
|
||||
self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f)
|
||||
|
||||
def test_end_to_end_tests_start_no_server_outside_their_container(self):
|
||||
"""Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything
|
||||
and never start ui/server.py: outside scripts/e2e.sh's container it would run on
|
||||
this computer's real app data, ~/.ssh and cache."""
|
||||
e2e = str(Path(__file__).resolve().parent / "e2e")
|
||||
code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n"
|
||||
"harness.Server.start = classmethod(lambda cls: calls.append(cls))\n"
|
||||
"suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n"
|
||||
"r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n"
|
||||
"print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))")
|
||||
for extra in ({}, {"FRAME_E2E": "1"}):
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")}
|
||||
env.update(extra, PYTHONDONTWRITEBYTECODE="1")
|
||||
out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True,
|
||||
timeout=120)
|
||||
self.assertEqual(out.returncode, 0, out.stderr[-2000:])
|
||||
started, ran, skipped, broken = map(int, out.stdout.split())
|
||||
self.assertEqual((started, broken), (0, 0), (extra, out.stdout))
|
||||
self.assertEqual(ran, skipped, (extra, out.stdout))
|
||||
self.assertGreater(skipped, 0, extra)
|
||||
|
||||
def test_child_processes_inherit_it(self):
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"
|
||||
" from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)",
|
||||
str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
capture_output=True, text=True, check=True).stdout.split("\n")
|
||||
for d in filter(None, out):
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
self.assertTrue(inside(d, tempfile.gettempdir()), d)
|
||||
|
||||
def test_real_ssh_folder_untouched_by_tests_that_start_servers(self):
|
||||
"""Reruns, from the environment the suite was started in, the tests that used to
|
||||
leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect
|
||||
tests, and checks nothing under the real ~/.ssh changed."""
|
||||
real = sandbox.REAL_SSH_DIR
|
||||
if inside(real, tempfile.gettempdir()):
|
||||
self.skipTest("this run's HOME is already a temporary folder")
|
||||
before = snapshot(real)
|
||||
env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"}
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode",
|
||||
"test_devices", "test_connect"],
|
||||
cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-3000:])
|
||||
after = snapshot(real)
|
||||
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)
|
||||
and not k.startswith("agent")) # ssh-agent sockets come and go on their own
|
||||
self.assertEqual(changed, [], f"tests changed the real {real}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<html", payload.lower())
|
||||
|
||||
def test_control_center_assets_served_with_correct_types(self):
|
||||
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
|
||||
('/control-center.css', 'text/css', b'.cc-workspace')):
|
||||
with self.subTest(path=path):
|
||||
status, headers, payload = self.request('GET', path)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
@@ -463,6 +472,11 @@ class PageScripts(unittest.TestCase):
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
def test_meta_library_rows_render(self):
|
||||
"""tests/page/meta_rows.mjs runs the real Meta library row rendering from index.html."""
|
||||
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||
class LocalMode(unittest.TestCase):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
@@ -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()
|
||||
+203
-6
@@ -138,7 +138,7 @@ class Gates(Base):
|
||||
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
|
||||
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
|
||||
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
|
||||
["frame_unreachable", "other", "other", "other"])
|
||||
["frame_unreachable", "internal_error", "other", "other"])
|
||||
|
||||
def test_page_events_are_checked(self):
|
||||
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
|
||||
@@ -159,11 +159,79 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
def test_frame_once_per_headset_with_saved_count(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
|
||||
props = [e["properties"] for e in self.queued()]
|
||||
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
|
||||
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
|
||||
|
||||
def test_frame_many_headsets_are_not_sent_twice(self):
|
||||
ids = ["%016x" % n for n in range(25)]
|
||||
for _ in range(2):
|
||||
for h in ids:
|
||||
tm.frame_seen("20260922.1", "3.8", h, 25)
|
||||
self.assertEqual(len(self.queued()), 25)
|
||||
|
||||
def test_frame_headset_id_must_be_the_hash(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
|
||||
p = self.queued()[0]["properties"]
|
||||
self.assertNotIn("headset_id", p)
|
||||
self.assertNotIn("headsets_saved", p)
|
||||
|
||||
|
||||
class HeadsetId(unittest.TestCase):
|
||||
def test_hash_of_machine_id_and_none_without_one(self):
|
||||
import hashlib
|
||||
import frame_status
|
||||
with mock.patch.object(frame_status, "read", return_value="abc123"):
|
||||
self.assertEqual(frame_status.headset_id(),
|
||||
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
|
||||
with mock.patch.object(frame_status, "read", return_value=None):
|
||||
self.assertIsNone(frame_status.headset_id())
|
||||
|
||||
|
||||
class Sending(Base):
|
||||
def serve(self, status=200):
|
||||
@@ -198,6 +266,39 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
@@ -263,6 +364,72 @@ class Scrub(unittest.TestCase):
|
||||
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
|
||||
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
|
||||
|
||||
def test_apk_install_failures_that_were_other(self):
|
||||
"""What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
|
||||
(or as frame_unreachable, when it was the Steam client or a slow copy)."""
|
||||
tail = 'Traceback (most recent call last):\n File "<stdin>", line 19, in target_ws\n'
|
||||
cases = {
|
||||
"steam_not_running": [
|
||||
"SharedJSContext not found: is the Steam client running?",
|
||||
tail + "urllib.error.URLError: <urlopen error [Errno 111] Connection refused>",
|
||||
tail + "ConnectionRefusedError: [Errno 111] Connection refused",
|
||||
],
|
||||
"steam_not_responding": [
|
||||
"websocket handshake failed",
|
||||
tail + "TimeoutError: timed out",
|
||||
tail + "socket.timeout: timed out",
|
||||
tail + ' File "<stdin>", line 45, in _read\nEOFError',
|
||||
],
|
||||
"steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
|
||||
'{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
|
||||
"artwork_failed": [
|
||||
"Steam artwork renderer did not produce every slot",
|
||||
tail + "ValueError: incomplete artwork render",
|
||||
tail + "ValueError: hero render is 0 bytes of png",
|
||||
"could not prepare artwork: unknown artwork slot",
|
||||
tail + "ValueError: all five Steam artwork slots are required",
|
||||
],
|
||||
"copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
|
||||
"copying launch.sh to the Frame timed out"],
|
||||
"copy_failed": [
|
||||
"copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
|
||||
"so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
|
||||
"copying game.apk to the Frame failed: ",
|
||||
],
|
||||
"apk_unreadable": [
|
||||
"x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
|
||||
"x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
|
||||
"x.apk: the APK manifest has no package name",
|
||||
"x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
|
||||
"x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
|
||||
],
|
||||
}
|
||||
for category, messages in cases.items():
|
||||
for message in messages:
|
||||
self.assertEqual(tm.categorize(message)[0], category, message)
|
||||
# Still where they were: ssh's own failures, and a full disk during a copy.
|
||||
self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
|
||||
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
|
||||
"port 22: Connection refused")[0], "frame_unreachable")
|
||||
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
|
||||
"\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
|
||||
self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
|
||||
|
||||
def test_python_errors_are_internal_whatever_they_say(self):
|
||||
import json as _json
|
||||
for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
|
||||
AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
|
||||
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
|
||||
KeyError("timed out")):
|
||||
self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
|
||||
self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
|
||||
self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
|
||||
|
||||
def test_privacy_doc_lists_every_category(self):
|
||||
doc = (ROOT / "docs" / "privacy.md").read_text()
|
||||
for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
|
||||
self.assertIn(f"`{name}`", doc)
|
||||
|
||||
|
||||
class Compat(Base):
|
||||
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
|
||||
@@ -376,14 +543,17 @@ class LinkFailureProvenance(Base):
|
||||
c.close()
|
||||
return result
|
||||
|
||||
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
|
||||
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
|
||||
# Failure; after the status poll's first connection failure, theirs are held back too,
|
||||
# also past the 10-minute window.
|
||||
def serve(self):
|
||||
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.httpd.server_close)
|
||||
self.addCleanup(self.httpd.shutdown)
|
||||
|
||||
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
|
||||
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
|
||||
# Failure; after the first connection failure, theirs are held back too, also past the
|
||||
# 10-minute window.
|
||||
self.serve()
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
|
||||
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
|
||||
@@ -399,7 +569,34 @@ class LinkFailureProvenance(Base):
|
||||
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
|
||||
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
|
||||
if e["event"] == "$exception"]
|
||||
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
|
||||
# The comfort poll's own failure isn't one (see the next test).
|
||||
self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
|
||||
|
||||
def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
|
||||
# 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
|
||||
# refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
|
||||
# by hand still is, once, and a real failure of the poll is too.
|
||||
self.serve()
|
||||
for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
|
||||
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
|
||||
"ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
|
||||
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
|
||||
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
|
||||
"checking.\r\nHost key verification failed.\r\n",
|
||||
"steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down):
|
||||
self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
|
||||
self.assertEqual(self.queued(), [])
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down):
|
||||
self.request("/api/comfort", {"action": "cancel"})
|
||||
broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=broken):
|
||||
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
|
||||
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
|
||||
if e["event"] == "$exception"]
|
||||
self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
|
||||
|
||||
def test_download_failures_after_a_poll_failure_are_still_sent(self):
|
||||
# Review round 3: these file names, in the real checksum message through the web-link worker,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -45,6 +45,10 @@ class SourceError(Exception):
|
||||
"""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):
|
||||
"""The source's host asked us to slow down; retry_after is in seconds."""
|
||||
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user