Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 73b97fedc5 windows-vm.sh: stop the input helper before giving up on a click
On a timeout or a setup error the request now stops the scheduled task and
waits for it to end before releasing the mutex, so a stalled helper can't
act later inside another request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:50:13 +10:00
saphidandClaude Opus 5.5 924e37aa30 windows-vm.sh: serialise clicks with a named mutex instead of a lock file
Windows releases the mutex when the request's process ends, so there's no
stale lock to recover and no window where two requests can take each
other's. Requests sent together now run one after another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:35 +10:00
saphidandClaude Opus 5.5 bd24436fbc windows-vm.sh: an atomic click lock, and setup errors fail the click
input.txt is now created with CreateNew, so only one request can hold it; a
lock older than 15 s is treated as left by a dead run. The outer PowerShell
stops on any error, says why in one line, releases the lock and exits 1.
docs/testing.md names both keys Set Up Connection can add to the headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:07 +10:00
saphidandClaude Opus 5.5 895cc0ebbd windows-vm.sh: review fixes for the headset key and input helper
- frame-key remove deletes only the exact line add wrote, through a private
  temporary file, and leaves authorized_keys alone if it can't read or
  rewrite it. add checks the VM's key is a plain ed25519 key, keeps a last
  line without a newline intact, and doesn't duplicate a key the headset
  already trusts.
- The input helper stops on any error before acknowledging, refuses a
  second click while one is in flight, and coordinates are bounded.
- Host, container and user names are checked before they reach a remote
  shell; PowerShell errors come back as plain text.
- docs/testing.md says what frame-key does and doesn't undo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:35 +10:00
saphidandClaude Opus 5.5 f7dbad500a Docs: signing in over RDP as steamos reaches a Plasma desktop (verified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:24:48 +10:00
saphidandClaude Opus 5.5 3f20e5f9d3 Windows test VM: a driver script and how to set one up
scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over
ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's
screendump, clicks and scrolls replayed by a scheduled task in the signed-in
session, QEMU key names, and a tagged, removable headset key for tests
against a real Frame. docs/testing.md explains setting it up (Windows from
Microsoft, loopback-only ports) and the pitfalls found reproducing the
Windows "RDP not working" report. docs/streaming.md records what Remote
Desktop from Windows does against the Frame's xrdp (verified 2026-09-30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:29:56 +10:00
128 changed files with 1096 additions and 19196 deletions

No files matched your search

-53
View File
@@ -1,53 +0,0 @@
name: PC host libraries
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "mac/frame-mac-view/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
- uses: actions/upload-artifact@v4
with:
name: pc-host-${{ matrix.os }}
path: desktop/bundle
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-python
mingw-w64-ucrt-x86_64-gstreamer
mingw-w64-ucrt-x86_64-gst-plugins-base
mingw-w64-ucrt-x86_64-gst-plugins-good
mingw-w64-ucrt-x86_64-gst-plugins-bad
mingw-w64-ucrt-x86_64-gst-plugins-ugly
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py
FRAME_PC_REQUIRE_NATIVE=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
- uses: actions/upload-artifact@v4
with:
name: pc-host-windows
path: desktop/bundle
+4 -93
View File
@@ -7,47 +7,14 @@ on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "desktop/**", "mac/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
workflow_dispatch:
permissions:
contents: write
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
needs: draft
# Still runs for pull requests and manual runs, where the draft job is skipped.
if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -61,83 +28,27 @@ 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.
- name: Check the Windows installer installs every file
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
-10
View File
@@ -7,15 +7,5 @@ compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
+1 -4
View File
@@ -213,19 +213,16 @@ 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 |
| [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>
-14
View File
@@ -6,25 +6,11 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
-79
View File
@@ -1,79 +0,0 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+8 -73
View File
@@ -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, session, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -11,7 +11,6 @@ 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);
@@ -116,11 +115,10 @@ function ping(target) {
}
async function startServer() {
// 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.
// The version 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);
@@ -151,7 +149,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -177,27 +175,18 @@ function stopServer() {
if (server) endServer(server);
}
function errorPage(message, title = "Frame Control couldn't start") {
function errorPage(message) {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -274,20 +263,7 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
@@ -349,47 +325,6 @@ 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
-252
View File
@@ -1,252 +0,0 @@
// "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 };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+6 -28
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.7",
"version": "0.4.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -11,9 +11,8 @@
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 && electron-builder --linux --x64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
"dist:linux:arm64": "node build/fetch-deps.js linux arm64 && electron-builder --linux --arm64 --publish never"
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -30,7 +29,6 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -40,7 +38,6 @@
"preload.js",
"install-link.js",
"updater.js",
"meta-signin.js",
"package.json",
"build/icon.png"
],
@@ -52,7 +49,6 @@
"*.py",
"*.html",
"*.js",
"*.css",
"telemetry.json"
]
},
@@ -85,7 +81,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so.gz"
"prebuilt/**/*.so"
]
},
{
@@ -185,16 +181,7 @@
"entry": {
"StartupWMClass": "frame-control"
}
},
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
}
},
"deb": {
"depends": [
@@ -207,16 +194,7 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
"artifactName": "Frame-Control-win-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
+3 -12
View File
@@ -2,20 +2,17 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It can open Set Up Connection when the headset can't be reached, and keeps the
// Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// 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. It opens the Sign in
// with Meta window and signs out of it (app/meta-signin.js).
// And it passes update state both ways: see app/updater.js.
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
@@ -40,12 +37,6 @@ 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 }));
-578
View File
@@ -1,578 +0,0 @@
// 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");
});
-21
View File
@@ -51,27 +51,6 @@ test("update.json assets always download from this repository's release", () =>
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
+5 -18
View File
@@ -106,24 +106,12 @@ async function getJson(url, headers) {
return JSON.parse(body);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
page: m.page || RELEASES,
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
@@ -131,9 +119,8 @@ function fromManifest(m) {
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
page: r.html_url || RELEASES,
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
@@ -1,311 +0,0 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -1,302 +0,0 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
-16
View File
@@ -1,16 +0,0 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad', 'x264']
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
build(parser.parse_args().out.resolve())
-125
View File
@@ -1,125 +0,0 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
-127
View File
@@ -1,127 +0,0 @@
/* Extracted from the Mac agent's RateController. Same gate, demand protection,
* bitrate recovery and tier hysteresis for every host. Bounded sample buffers. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
-29
View File
@@ -1,29 +0,0 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
-147
View File
@@ -1,147 +0,0 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
-6
View File
@@ -39,12 +39,6 @@ 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.
+6 -56
View File
@@ -104,9 +104,7 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
headset makes the connector start again.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
@@ -123,57 +121,9 @@ lease that moved) would look like a new host there. The app keeps one known_host
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`), along with a flag that never clears
(`identity_established`), as soon as ssh saves the key: even if the login is then
refused. A headset counts as having had an identity if the flag is set, a
fingerprint is recorded, its pin file exists at all, or a connection to it ever
worked (older files get the flag from that when loaded, so editing or removing
the address that worked doesn't undo it); then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
no local command (ProxyJump's own ssh would take `ForwardAgent` and
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
configured. Every word of that ProxyCommand is quoted for the shell that runs it
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
it refuses, since those can't be made safe from here: add an address it can
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
Devices tab offer **Set Up Connection…**, which pairs this computer again.
Neither is reported as a fault: the diagnostics report each kind at most once
a session ([privacy.md](privacy.md)).
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
connection save the new one.
## One server at a time
@@ -194,7 +144,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
@@ -211,7 +161,7 @@ share the format later (it still connects to one host; see
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+5 -66
View File
@@ -17,12 +17,11 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has six tabs: **Home** (headset view, status, screenshots),
The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (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. Files can be dropped anywhere in the window. A
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
@@ -89,68 +88,8 @@ counts them while they run.
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
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.
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

-371
View File
@@ -1,371 +0,0 @@
# 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.
-199
View File
@@ -1,199 +0,0 @@
# PC in the headset
Windows and Linux hosts use **Tools → PC in the headset**. The host shares a
window or screen, and the existing Frame viewer makes it a SteamVR panel.
Move it with the dashboard's Float in World, Move and Size controls.
**Inferred / not yet verified on a desktop host:** the Windows and Linux
capture and input paths below. This work was developed on a Mac with no
Windows or Linux desktop VM. A native build or test-pattern test in CI does
not establish that desktop capture, a permission dialog, hardware encoding
or laser input works. Keep this feature in the draft/testing stage until
those paths have been tried on real hosts.
## Own implementation, platform APIs and bundled libraries
Frame Control owns the host agent, input routing, authentication, streaming
protocol, panel launcher and adaptation. It does not launch or require
Sunshine, OBS or another desktop-streaming app. GStreamer and its codec
plugins are ordinary libraries bundled with the Windows and Linux app;
users do not install a GStreamer application. The shared library build keeps
license texts and package provenance alongside the libraries. Linux also
bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a
private client configuration; it does not change the desktop’s configuration.
First-party alternatives considered (**documented**): Valve Remote Play
streams a game/desktop, rather than providing this per-window panel protocol;
Windows Remote Desktop opens a remote session; Linux's desktop portal is the
consent mechanism for sharing the current desktop. The chosen paths are:
| Host | Capture | Encoding | Input |
|---|---|---|---|
| Windows | Windows.Graphics.Capture, through `d3d11screencapturesrc capture-api=wgc`; HWND or HMONITOR | Hardware Media Foundation (`mfh264enc`), low latency, no B-frames | `SendInput`, with source bounds and per-monitor DPI awareness |
| Linux | RemoteDesktop + ScreenCast portal, then the returned PipeWire fd/node | VA-API (`vah264enc`) where registered; x264 otherwise | RemoteDesktop portal notifications, using only granted pointer/keyboard devices |
API choices are **documented**, not device verification:
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html),
[ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html),
[RemoteDesktop portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html).
Windows needs a WGC-capable Windows 10/11 desktop and an available hardware
Media Foundation H.264 encoder. Elevated windows and the secure desktop
cannot be driven by an ordinary Frame Control process. If Windows refuses
to focus a selected window, input is refused rather than sent to the app
covering it; bring the selected window forward, then Stop and Show again. Minimized/closed
windows may stop producing frames. Protected content is not supported.
On Linux, press **Choose a window or screen…** and approve the desktop's
sharing dialog. Choose another source to add another panel. Stop releases
that source's portal session; sharing it again asks for consent again.
A desktop must implement both ScreenCast and RemoteDesktop for this path;
a ScreenCast-only compositor cannot provide laser input through this API.
Cancelling or denying a dialog is reported on the card. No portal permission
is bypassed, and Frame Control does not open `/dev/uinput` or the unrestricted
PipeWire daemon on the host.
The host's own keyboard still works. Input from the viewer uses normalized
picture coordinates, maps through the selected source's bounds, and releases
held buttons/keys on blur, disconnect and Stop. Linux requires the pointer
and keyboard grants. **Untested:** desktop-specific consent, mixed-DPI
Windows input alignment, multi-monitor layouts, hardware encoder behavior,
window resize/minimize, and non-US keyboard layouts.
## Shared pieces
- `ui/frame_macview.py` owns the SSH tunnel, reconnect supervision, quality
presets and panel launch for all hosts. `ui/frame_pcview.py` selects the PC
helper; `/api/macview` remains the compatible endpoint.
- `ui/mac-view.html` is the one viewer. The 17-byte big-endian frame header,
Annex-B H.264/JPEG payloads, `hello`/`ack` reconnect handshake, clock sync,
`rx`/`fd` timing reports and input messages are unchanged.
- `desktop/controller.c` is the rate controller shared by the Mac Swift
binding and the PC Python binding. Capture is gated **before** encoding;
encoded reference frames are never discarded. A bounded raw-frame queue
keeps the newest picture, including the last update of an idle window,
until the gate opens. A native one-frame-source test covers that case.
It keeps the Mac's bitrate demand protection and tier hysteresis.
- PC records use the existing `Stats.swift` JSON schema, with bounded
4096-frame/512-input storage in `ui/frame_stream_stats.py`. The benchmark's
analysis, targets and network shaping are shared, not reimplemented.
Capture timestamps describe the native pipeline's source time; they do
not prove the time at which the host compositor displayed the pixels.
- Mac virtual-display separation remains Mac-only. Windows WGC and the
Linux portal share the selected window directly.
PC capture follows the shared frame-rate and resolution tiers. x264 updates
bitrate while running. Hardware encoders are drained and reopened when the
budget changes materially, at most once a second, because their live property
support varies. Reopening starts a new keyframe and retains the consented
portal session. **Untested:** hardware reconfiguration latency and whether a
particular desktop permits reconnecting its PipeWire stream this way.
## Build and measure
Packaged Windows/Linux builds include `desktop/bundle/pc-host` and its shared
libraries. Source checkouts build them with `python3 desktop/build.py` after
installing GStreamer development packages (see the `PC host libraries` CI
workflow). The feature reports a missing bundle; it does not download or
install a streaming app on first use.
The existing benchmark now accepts a PC host:
```sh
python3 scripts/macview-bench.py run --pc --scenario test --label pc-test
# Linux: select a real source in the desktop's sharing dialog
python3 scripts/macview-bench.py run --pc --scenario capture --source choose --label linux-window
# Windows: use the HWND/monitor source ID shown by the host's /windows or /displays
python3 scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
```
The synthetic PC pattern uses bundled x264 so headless CI can verify the
wire protocol without claiming that a GPU was exercised. The `capture`
scenario measures the selected real source without injecting input or
assuming that it animates at 60 fps. Mac-only Chrome/virtual-display typing
and scrolling automation is not run on PC hosts. Results retain the same
latency stages and record `host_platform`, `pc_host` and `source`. CPU sampling
on PC hosts is explicitly unavailable. `--net` and `--delay` still use the
same bounded shaping relay, without administrator privileges.
## Evidence
- **Verified, Mac, 2026-09-28:** the final full unit/integration suite ran
183 tests successfully, including the real Mac helper's H.264, ticket, timing
and input-echo tests. The native PC test class was skipped locally because
its libraries were absent; the PC adapter and shared-controller tests passed.
- **Verified, real Frame, 2026-09-28, BUILD_ID 20260925.6191901:** the base
helper's synthetic source created panel `valve.steam.desktopgame.2001639889`,
and the shared Chromium viewer decoded H.264. It recorded 286 frames over
the short probe, with a two-second summary of 19.5 fps shown and total
latency p50/p95 83/156.5 ms. This establishes the existing viewer/transport
route, not Windows/Linux capture, input or a latency target. The probe's
helper, tunnel and viewer were stopped afterward.
- **Verified in CI:** native library builds and real x264 protocol tests passed
on Windows, Ubuntu x64 and Ubuntu ARM64 in
[run 36422214445](https://github.com/saphid/frame-control/actions/runs/36422214445).
All four installer builds passed in
[run 36422214425](https://github.com/saphid/frame-control/actions/runs/36422214425).
These are build/synthetic tests, not desktop-host verification. No VM was used.
- **Verified, real Frame, same build/date:** the ARM64 PC agent and its
bundled libraries ran from a temporary user directory, using x264's moving
test pattern. Traffic travelled Frame → Mac SSH relay → Frame viewer.
The shared bench recorded 351 drawn frames, content p50/p95 15.1/80.3 ms,
and 34.8 fps. The frame-rate and late-frame targets failed. This checks the
new agent and real viewer together; it is not a representative PC link.
The helper exited 0, its viewer/tunnels stopped, and its directory was removed.
[Raw benchmark result](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
This first probe's input echo measured message receipt to the next capture,
not a visible pattern response; later builds make test clicks change its color.
- **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace
matched the original Swift controller. `tests/test_pc_controller.py` retains
the original trace digest as a regression check.
- **Verified, real Frame, agent at `cd20243`:** the repeated synthetic
probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic
input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before
injection is timestamped. The frame-rate/late-frame targets still failed;
this remains a Frame-hosted x264 test through a Mac relay, not a desktop or
physical-laser measurement. Helper exit 0 and cleanup succeeded.
[Latest device probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json).
- **Untested:** real Windows WGC → Media Foundation → Frame; real Linux
portal → PipeWire → VA-API/x264 → Frame; physical laser input on either.
No benchmark numbers for those desktop paths are claimed.
## Independent review availability
A direct read-only review was attempted with
`devin -p --model swe-2-max --permission-mode auto --prompt-file …`. The first
attempt exited 0 after rejecting a tool that needed interactive permission;
it did not inspect the diff. A full inline-diff attempt returned no output
for 15 minutes and was terminated (shell exit 143). A smaller inline native
code review returned no output within 300 seconds (process exit -15).
SWE-2 Max was requested; no completed review or findings were received, so
independent review is **unverified**, not a passed check. The PR remains draft.
## Device follow-up, 2026-09-29
**Verified, read-only, SteamOS BUILD_ID 20260925.6191901:** the Frame was
reachable, charging (44% initially, 63% at the end), and SteamVR reported
activity level 3 (standby). No Plasma desktop was running. Introspection of
the active `org.freedesktop.portal.Desktop` service exposed ScreenCast with
source types 3, but no RemoteDesktop interface. **Inferred:** this gamescope
session cannot provide this feature's required consented input path; a
ScreenCast-only session is insufficient.
**Blocked, not a device execution result:** the shared `/tmp/frame-test.lock`
was held by another thread. Ten lock-acquisition attempts over five minutes
all failed, after earlier preparation-time attempts also found it occupied.
The current `a992f6d` ARM64 CI bundle was downloaded to the Mac, but nothing
was installed, launched or stopped on the Frame. No new screenshots or
current-revision device timings were obtained. The lock was not removed and
no global settings were changed. The earlier synthetic results above remain
valid only for their named revisions; standby visibility, physical laser
input and worn-headset performance remain unverified.
**Verified, Mac:** a fresh full suite ran 309 tests successfully, with one
native-PC class skipped. Current-revision CI passed
[native hosts](https://github.com/saphid/frame-control/actions/runs/36504546909),
[installers](https://github.com/saphid/frame-control/actions/runs/36504546800)
and [general checks](https://github.com/saphid/frame-control/actions/runs/36504546887).
+16 -162
View File
@@ -33,46 +33,23 @@ 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 | `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` |
| `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` |
| `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 |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
`install_finished` never includes a file name, path or error message. An
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`, `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:
error becomes one category from a fixed list (for example `apk_wrong_abi` or
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
is one. 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
@@ -90,21 +67,13 @@ 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`, `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,
- 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,
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.
@@ -124,11 +93,7 @@ scrubbed:
names, passwords, ports, paths and queries are dropped
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes. When ssh reports that
it couldn't reach the Frame (asleep, away, or not set up yet), or that ssh and
the Frame refused each other's keys (a changed host key, or this computer's
key not accepted), that is sent at most once per kind each time Frame Control
runs.
The same error is sent at most once every 10 minutes.
## Report a problem
@@ -138,18 +103,9 @@ 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
the person sends it deliberately. The report has the kind, title and text you
wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
your analytics events. With that box ticked, the address also becomes your
**Contact email** below with follow-up questions ticked, so you remove it there
like any other. If it's a different address from the one saved there, it
replaces it, and update notices stop until you turn them on again (they were
agreed for the old address); the form says so before you send. The report then also
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
see below), so removing or changing the address later takes back the
follow-up permission given with the report too.
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
With **Include diagnostics** ticked (the default), the report adds:
@@ -157,56 +113,10 @@ With **Include diagnostics** ticked (the default), the report adds:
- the OS, its release and CPU, and the Python version
- the Frame's SteamOS build, if it has connected since the app started
- which analytics levels are on
- a short connection summary, so "the app can't find the headset" can be
diagnosed. It is made of fixed words and counts only; no text from an
error message or from ssh, and no name you chose, goes in it:
- the connector's state (idle, connecting, connected or failed), the stage
it failed at (network, find, ssh, identity or login), the attempt number
and why it started (such as "Starting up" or "Trying again")
- how many headsets are saved; whether the active one's ssh alias is the
default `frame` or a custom one (a custom alias itself is never named);
whether it's saved or a bare ssh alias; and its number of addresses
- for the current error and the last failure: the stage and an error
category (the same fixed names as error details, such as
`frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
how long ago the last failure was. The category is decided when the
failure happens
- for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
`ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
`~/.ssh/config`, and what a name resolved to, such as
`.local->ipv6 link-local`) and how the try went (answered, unresolved,
timeout, refused, unreachable, sshfailed). Never the address itself
- when connected, the kind of address used and its round trip in ms
- whether this computer has a network gateway, and whether Tailscale is on,
off or not installed
- which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
Nix, the system one, or "other"), never its path; its version, rebuilt
from the numbers in the banner `ssh -V` prints (such as
`OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
plain OpenSSH banner is reported as `unknown`); and the kinds of any
other `ssh` on the PATH.
This is looked up once in the background after the app starts, so a
report sent straight away may say "still being checked"
- whether `~/.ssh/config` exists, whether it has Set Up Connection's
managed block for the active alias, how many managed blocks it has, and
whether a hand-written `Host` line also names the alias (yes or no; the
alias isn't named)
**Also include recent activity and the server log** is off by default,
because those lines can name files and apps. When ticked, it adds the newest
Activity lines and server log lines, without the request lines. The server
log has a line for each failed connection attempt: its stage, the message
shown on the connection pill, ssh's last line about it, and the address kinds
above. That free text is scrubbed when the line is written: the addresses, ssh alias
and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Activity lines and server log lines, without the request lines.
Everything is scrubbed like error details and limited to what fits in the
report. Environment details are kept first, then the newest lines. **Show
@@ -218,67 +128,11 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never in the same visit as the first-run
privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, or when you send a problem report with follow-up questions ticked,
whatever the analytics settings are, because you chose to. With a report, the
address and choices are saved before the report is sent and stay saved if it
fails; like any change, they're sent as soon as PostHog can be reached. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. This also covers problem reports you sent
from this copy with follow-up questions ticked: if your newest choice since the
report (by change number, not the clock) no longer agrees to follow-up
questions at that address, the maintainer's inbox shows the permission as
withdrawn and leaves the address out. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
## Update checks
-219
View File
@@ -1,219 +0,0 @@
# 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.
+4 -11
View File
@@ -24,20 +24,13 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all eight installers
are attached, each with the SHA-256 digest GitHub records. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
uses only the REST API: `gh release view` can't find a draft whose
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
stops working once it's published. From then on, running copies see the update. They check about 8
The script checks that all eight installers are attached, each with the
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
notes and each installer's digest), then publishes the release and marks it
latest. From then on, running copies see the update. They check about 8
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
+14 -24
View File
@@ -21,24 +21,24 @@ 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.
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
Mode password opens a Plasma (X11) desktop within about 6 seconds.
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
fills in `steamos` there on Windows, Remmina and FreeRDP.
- The desktop is a separate login session (Xorg on display `:10`), not the
headset's view. It uses about 1.3 GB of the Frame's memory.
- Closing the client leaves the session running, and the next login
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
gamescope or SteamVR session.
## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
@@ -261,16 +261,6 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
-window_id <window> -i :<display>` works on gamescope's redirected windows,
while grabbing the root gives black. It streams as H.264 like the headset view
(about 30 fps at 720p).
- **When no panel is found**, `frame_touch.py panels` says why (`"why"`), and the
Desktop view shows that instead of a bare "no app panel": X tools missing,
gamescope listing windows that aren't X11 windows on any display, the desktop
running but not listed, or nothing open. It reads gamescope's properties from
`:0` first (one `xprop` call) and probes the other displays, 2 s each, only if `:0`
has none. A whole run stops after 12 s, inside the server's 20 s, and says the headset
was too slow. Added for
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
open but not found). The likely cause there was the page itself: a second
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
+79 -32
View File
@@ -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,29 +16,6 @@ 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`.
@@ -186,16 +164,85 @@ 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
## Windows test VM
`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).
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
+2 -13
View File
@@ -214,16 +214,9 @@ 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: 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.
services. Frame Control won't work around that.
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
not a patch. They install with a warning that they may not run: some
OpenXR games ship `libvrapi.so` without depending on it.
not a patch.
## Frame Control does this for you
@@ -256,10 +249,6 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
and keeps the current refresh rate when SteamVR refuses a requested one.
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
If this copy of Frame Control is missing the layer's library (an incomplete
install, or a file removed after installing), VR apps install without it and
the install message says so; OpenXR 1.0 apps still run. Release builds fail
to package without it (`app/build/check-resources.js`).
Verified on the headset (2026-09-28):
+2 -5
View File
@@ -190,11 +190,8 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
extractor skips. Frame Control decompresses it when injecting the layer. The
SHA-256 of the uncompressed library is recorded below with the APK checks.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
Its SHA-256 is recorded below with the APK checks.
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
+2 -5
View File
@@ -8,8 +8,5 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
cmake --build "$here/build-android"
mkdir -p "$here/prebuilt/arm64-v8a"
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
# can't decode, so the installed app silently lacked the library.
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
Binary file not shown.
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/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,5 +26,3 @@
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
@end
#include "../../../desktop/controller.h"
+222 -33
View File
@@ -1,45 +1,234 @@
// Thin, serialized binding to the same controller used by the PC host.
// Algorithm and bounded state live in desktop/controller.c.
// Adapts each stream to its network, latency first. The viewer acknowledges
// every frame as it arrives ("rx"); from those acks the controller knows how
// long frames take to get through and how fast the link delivers them.
//
// - The gate: a new frame is sent only while the oldest unacknowledged one is
// younger than the path's usual round trip plus a little slack. So frames
// never queue up in SSH, TCP or the Wi-Fi driver; while the link is stuck
// the newest picture waits and goes out as soon as it moves again.
// - The bitrate: when frames start queueing (the round trip grows) or the
// gate has to hold frames back, it drops to a bit under what the link
// actually delivered; once things are clear it probes up again slowly, never
// above the quality setting's bitrate (the ceiling).
// - The tier: as the bitrate falls, fewer frames per second (60, 45, 30), then
// a smaller picture (75%, then 50% of the panel's pixels).
// See docs/mac-in-headset.md ("Adapting to the network"). Thread-safe.
import Foundation
final class RateController {
struct Tier: Equatable {
let fps: Int
let scale: Double // of the picture's long side
}
static let tiers = [Tier(fps: 60, scale: 1), Tier(fps: 45, scale: 1), Tier(fps: 30, scale: 1),
Tier(fps: 30, scale: 0.75), Tier(fps: 30, scale: 0.5)]
/// A tier is used while the target bitrate is at least this share of the ceiling.
static let floors = [0.45, 0.28, 0.16, 0.08, 0]
static let enabled = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ADAPT"] != "0"
let maxFps: Int
private let lock = NSLock()
private let core: OpaquePointer
private var events: [[String: Any]] = []
init(maxFps: Int) {
self.maxFps = maxFps
core = fc_new(Int32(maxFps), Self.enabled ? 1 : 0)!
}
deinit { fc_free(core) }
private var ceiling = 0 // bits/s at full size and frame rate
private(set) var target = 0
private(set) var tier = 0
private var unacked: [(seq: UInt32, sent: Int64, bytes: Int)] = []
/// Round trips (send -> ack arrives here), for the baseline: the lowest
/// in the last 10 s is the path without any queue.
private var rtts: [(t: Int64, v: Int64)] = []
private var acked: [(t: Int64, bytes: Int)] = [] // the last second
private var sentLog: [(t: Int64, bytes: Int)] = []
private var captures: [Int64] = [] // the last second
private var frameBytes = 0 // average recent frame, kept while the gate holds everything back
private var held = 0 // frames the gate held back since the last update
private var lastSignal = false
private var sawAck = false
private var lastDecrease: Int64 = 0
private var lastIncrease: Int64 = 0
private var belowSince: Int64 = 0, aboveSince: Int64 = 0
/// What changed, for the timeline: (time, event).
private(set) var events: [(Int64, String)] = []
init(maxFps: Int) { self.maxFps = maxFps }
private func locked<T>(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
private func value(_ field: Int32) -> Int { Int(fc_value(core, field)) }
var target: Int { locked { value(0) } }
var tier: Int { locked { value(2) } }
var fps: Int { locked { value(3) } }
var scale: Double { locked { Double(value(4)) / 100 } }
var baseRtt: Int64 { locked { fc_value(core, 5) } }
func setCeiling(_ bps: Int) { locked { fc_ceiling(core, Int32(bps)) } }
func maySend(now: Int64, counts: Bool = true) -> Bool { locked { fc_gate(core, now, counts ? 1 : 0) != 0 } }
func captured(at t: Int64) { locked { fc_capture(core, t) } }
func sent(seq: UInt32, bytes: Int, at t: Int64) { locked { fc_sent(core, seq, Int32(bytes), t) } }
func acked(seq: UInt32, at t: Int64) -> Bool { locked { fc_ack(core, seq, t) != 0 } }
func update(now: Int64) -> Int? {
/// The quality setting's bitrate at full size; the first call also starts there.
func setCeiling(_ bps: Int) {
locked {
let old = value(0), tier = value(2)
let result = Int(fc_update(core, now))
if value(0) < old || value(2) != tier {
events.append(["t": now, "e": "target \(value(0)) bit/s; tier \(value(2))"])
if events.count > 200 { events.removeFirst() }
}
return result == 0 ? nil : result
if target == 0 || target > bps { target = bps }
ceiling = bps
}
}
func state() -> [String: Any] {
locked { ["target": value(0), "ceiling": value(1), "tier": value(2), "fps": value(3),
"scale": Double(value(4)) / 100, "baseRtt": Double(value(5)) / 1000,
"inFlight": value(6), "slack": Double(value(7)) / 1000, "adapt": Self.enabled] }
var fps: Int { locked { fpsLocked } }
private var fpsLocked: Int { min(maxFps, RateController.tiers[tier].fps) }
var scale: Double { locked { RateController.tiers[tier].scale } }
var baseRtt: Int64 { locked { baseline() } }
private func baseline() -> Int64 { rtts.map(\.v).min() ?? 0 }
/// How late a frame may be before the gate holds the next one: one frame
/// interval, plus room for the jitter this link normally has (1.5 times
/// its recent spread), so ordinary Wi-Fi jitter doesn't cost frames but a
/// real queue does. Updated in update().
private var slack: Int64 = 40_000
/// Whether a frame may be sent now without queueing behind earlier ones.
/// `counts`: a held frame is a sign of congestion (not when merely
/// re-checking whether a held frame can go yet).
func maySend(now: Int64, counts: Bool = true) -> Bool {
locked {
guard RateController.enabled, sawAck else { return true } // not heard from the viewer yet
// Unacknowledged for 2 s: gone with a reconnection, not in a queue.
unacked.removeAll { now - $0.sent > 2_000_000 }
guard let oldest = unacked.first else { return true }
// Age is what bounds latency. The count only stops a burst, and it
// allows a full round trip of frames, so a long but clear path
// (100 ms away) still gets every frame.
let interval = Int64(1_000_000 / max(1, fpsLocked))
let window = max(3, Int((baseline() + slack) / interval) + 1)
if unacked.count < window, now - oldest.sent <= baseline() + slack { return true }
if counts { held += 1 }
return false
}
}
func eventList() -> [[String: Any]] { locked { events } }
/// A picture was captured (sent or not): with the frame sizes, what this
/// stream would send if the link allowed.
func captured(at t: Int64) {
locked {
captures.append(t)
if captures.count > 256 { captures.removeFirst(captures.count - 256) }
}
}
func sent(seq: UInt32, bytes: Int, at t: Int64) {
locked {
// Bounded even if the viewer never acknowledges (an old viewer, or
// the controller is off).
unacked.append((seq, t, bytes))
if unacked.count > 512 { unacked.removeFirst(unacked.count - 512) }
sentLog.append((t, bytes))
if sentLog.count > 1024 { sentLog.removeFirst(sentLog.count - 1024) }
}
}
/// The viewer has frame `seq`. Returns true if that may let a held frame go.
func acked(seq: UInt32, at now: Int64) -> Bool {
locked {
sawAck = true
guard let i = unacked.firstIndex(where: { $0.seq == seq }) else { return false }
let f = unacked[i]
unacked.removeSubrange(0...i) // TCP delivers in order: earlier ones arrived too
rtts.append((now, now - f.sent))
acked.append((now, f.bytes))
return true
}
}
/// Called every 100 ms. Returns the new bitrate target, or nil if the
/// controller is off.
func update(now: Int64) -> Int? {
locked {
rtts.removeAll { now - $0.t > 10_000_000 }
acked.removeAll { now - $0.t > 500_000 }
sentLog.removeAll { now - $0.t > 500_000 }
unacked.removeAll { now - $0.sent > 2_000_000 }
captures.removeAll { now - $0 > 1_000_000 }
guard RateController.enabled, ceiling > 0 else { return nil }
let base = baseline()
let spread = rtts.filter { now - $0.t < 2_000_000 }.map(\.v).sorted()
let jitter = spread.isEmpty ? 0 : spread[spread.count * 9 / 10] - base
let interval = Int64(1_000_000 / max(1, fpsLocked))
slack = interval + min(max(jitter * 3 / 2, 25_000), 80_000)
let recent = rtts.filter { now - $0.t < 300_000 }.map(\.v).sorted()
let queueing = recent.isEmpty ? 0 : recent[recent.count / 2] - base
let oldestAge = unacked.first.map { now - $0.sent } ?? 0
let stuck = oldestAge > base + 100_000
let delivered = acked.reduce(0) { $0 + $1.bytes } * 16 // bits/s over the last half second
let sending = sentLog.reduce(0) { $0 + $1.bytes } * 16
// Demand: captures per second (up to the tier's rate) times the
// average frame. A test card or a mostly still window wants far
// less than its budget; when the link hiccups, cutting its bitrate
// can't help, and it would only look link-limited afterwards.
if !sentLog.isEmpty { frameBytes = sentLog.reduce(0) { $0 + $1.bytes } / sentLog.count }
let demand = min(captures.count, fpsLocked) * frameBytes * 8
// Delay alone isn't our queue: Wi-Fi jitters by itself. It only
// counts while this stream uses a good part of its budget (so its
// own data could be what's queueing). Frames the gate had to hold,
// or one stuck in flight, show demand the link isn't carrying
// whatever was sent (the gate itself keeps what's sent low).
let busy = sending >= target / 2
// Twice in a row (200 ms), so one late ack doesn't count.
let signal = (busy && queueing > 40_000) || held >= 3 || stuck
let congested = signal && lastSignal
lastSignal = signal
let heldNow = held
held = 0
let floorBps = 300_000
if congested, now - lastDecrease > 300_000 {
// Down to a bit under what got through: at least a fifth off, at
// most half (a stall delivers nothing, but the link is still there).
let measured = Int(Double(delivered) * 0.9)
var next = max(floorBps, min(target * 4 / 5, max(measured, target / 2)))
// App-limited (it wants about half its budget or less): never
// below twice what it wants, however many cuts in a row. The
// extra quarter is hysteresis, so frame sizes wobbling at the
// floor don't switch the protection off.
if demand > 0, demand * 2 <= target * 5 / 4 { next = max(next, min(target, demand * 2)) }
target = next
lastDecrease = now
events.append((now, "down to \(target / 1000) kbit/s: queue \(queueing / 1000) ms, held \(heldNow), "
+ "oldest \(oldestAge / 1000) ms, base \(base / 1000) ms, sent \(sending / 1000) got \(delivered / 1000) "
+ "wants \(demand / 1000)"))
} else if !congested, now - lastDecrease > 1_000_000, now - lastIncrease > 250_000, target < ceiling,
sending > target * 6 / 10 || now - lastDecrease > 3_000_000 {
// Clear for a second and using what it has: probe up.
target = min(ceiling, Int(Double(target) * 1.1) + 50_000)
lastIncrease = now
}
// Fewer frames or pixels only help a stream that fills its budget;
// a small one (a still window, a test card) keeps its tier.
retier(now: now, linkLimited: sending >= target * 7 / 10)
if events.count > 200 { events.removeFirst(events.count - 200) }
return target
}
}
/// Steps down quickly, straight to the tier the bitrate supports, and back
/// up one tier at a time only when there's clearly room (hysteresis).
private func retier(now: Int64, linkLimited: Bool) {
let share = Double(target) / Double(max(ceiling, 1))
if tier < RateController.tiers.count - 1, share < RateController.floors[tier], linkLimited {
if belowSince == 0 { belowSince = now }
if now - belowSince > 500_000 {
tier = RateController.floors.firstIndex { share >= $0 } ?? RateController.tiers.count - 1
belowSince = 0
events.append((now, "tier \(tier)"))
}
} else {
belowSince = 0
}
if tier > 0, share > RateController.floors[tier - 1] * 1.25 {
if aboveSince == 0 { aboveSince = now }
if now - aboveSince > 2_000_000 {
tier -= 1
aboveSince = 0
events.append((now, "tier \(tier)"))
}
} else {
aboveSince = 0
}
}
func state() -> [String: Any] {
locked {
["target": target, "ceiling": ceiling, "tier": tier, "fps": min(maxFps, RateController.tiers[tier].fps),
"scale": RateController.tiers[tier].scale, "baseRtt": Double(baseline()) / 1000,
"inFlight": unacked.count, "slack": Double(slack) / 1000, "adapt": RateController.enabled]
}
}
func eventList() -> [[String: Any]] { locked { events.map { ["t": $0.0, "e": $0.1] } } }
}
+1 -4
View File
@@ -5,10 +5,7 @@ set -eu
here=$(cd "$(dirname "$0")" && pwd)
out=${1:-$here/../bin/frame-mac-view}
mkdir -p "$(dirname "$out")"
obj=$(mktemp /tmp/frame-controller.XXXXXX)
trap 'rm -f "$obj"' EXIT
xcrun clang -O2 -target arm64-apple-macos14.0 -c "$here/../../desktop/controller.c" -o "$obj"
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
-o "$out" "$here"/Sources/*.swift "$obj"
-o "$out" "$here"/Sources/*.swift
echo "built $out"
+13 -55
View File
@@ -55,7 +55,6 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
RESULTS = ROOT / "bench" / "results"
PAGES = ROOT / "bench" / "pages"
@@ -255,10 +254,7 @@ class LabView(frame_macview.MacView):
def ssh_runner(base):
def run(remote, stdin=None, timeout=30):
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
capture_output=True, timeout=timeout)
r.stdout = r.stdout.decode("utf-8", errors="replace")
r.stderr = r.stderr.decode("utf-8", errors="replace")
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
if r.returncode:
e = RuntimeError((r.stderr or r.stdout).strip())
e.stdout = r.stdout
@@ -385,11 +381,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if args.browser_flag is not None:
mv.browser_flags = [f for f in args.browser_flag if f]
chrome = None
src = args.source or "test"
cpu = None
if args.pc:
mv.host = "windows" if sys.platform == "win32" else "linux"
mv.viewer_profile = "pc-view"
src = "test"
try:
if scenario in ("scroll", "type"):
chrome = chrome_window(f"{scenario}.html")
@@ -413,10 +405,9 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
since = max([f["s"] for f in first["frames"]] or [0])
captured_before = first["captured"]
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
if not args.pc:
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
if relay:
relay.begin()
expected = 0 # input events the harness asked the viewer for
@@ -452,8 +443,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if not streams:
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
data = streams[0]
if cpu:
cpu.stop()
cpu.stop()
if args.raw:
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
@@ -462,7 +452,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
for e in data.get("events", []) if e["t"] >= start_mac_us]
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
result["cpu"] = cpu.summary()
result["viewer"] = data["summary"].get("decoder", "")
result["show_s"] = show_s
result["panel"] = shown.get("panel")
@@ -470,8 +460,6 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
result["route"] = mv.route
return result
finally:
if cpu:
cpu.stop()
try:
mv.stop(src)
except Exception: # noqa: BLE001 - best effort
@@ -682,8 +670,6 @@ def fmt(v):
def add_run_args(r):
r.add_argument("--scenario", default="test,scroll,type")
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
r.add_argument("--duration", type=float, default=20)
r.add_argument("--warmup", type=float, default=3)
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
@@ -731,7 +717,7 @@ def frame_ssh_for(args):
def run_suite(args, frame_ssh, quiet=False):
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
agent_port, token = lab_agent()
results = []
for sc in args.scenario.split(","):
if not quiet:
@@ -758,12 +744,11 @@ def document(args, frame_ssh, results, **extra):
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
return {
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
"scenarios": results, **extra,
}
@@ -834,46 +819,19 @@ def main():
print("\nworse:\n " + "\n ".join(regs))
sys.exit(1 if regs else 0)
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
if args.pc and args.scenario == "capture" and not args.source:
p.error("capture needs --source window:ID, display:ID, or choose")
if not args.pc:
lab_agent()
lab_agent()
frame_ssh = frame_ssh_for(args)
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
args.pc_view = None
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
try:
if args.pc:
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
args.pc_view.ensure_agent()
if args.source == "choose":
args.pc_view.call("/permissions", method="POST")
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
deadline = time.monotonic()+125
while time.monotonic() < deadline:
state = args.pc_view.call("/status")
if not state.get("selecting"):
sources = args.pc_view.call("/windows")["windows"]
if not sources:
raise SystemExit(state.get("selectionError") or "Nothing was shared")
args.source = sources[-1]["src"]
break
time.sleep(.5)
else:
raise SystemExit("Sharing dialog timed out")
if args.cmd == "ab":
arm_runs, table = ab(args, frame_ssh)
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
return
results = run_suite(args, frame_ssh)
finally:
if args.pc_view:
args.pc_view.shutdown()
if awake:
awake.terminate()
awake.terminate()
doc = document(args, frame_ssh, results)
write(doc, args)
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
+16 -74
View File
@@ -3,65 +3,23 @@
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
#
# Everything goes through the REST API (gh api), not `gh release view/edit`:
# those look a draft up by its tag, and a draft can show tag_name
# "untagged-..." until it's published, so they report "release not found"
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
# link that dies on publishing, so update.json's page is built from the tag.
# Usage: scripts/publish-release.sh v0.4.0
set -eu
dry=""
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
tag="${1:?usage: $0 vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
page="https://github.com/$repo/releases/tag/$tag"
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
# the default branch, which isn't what was built and tested.
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
import json, re, sys
tag, path = sys.argv[1], sys.argv[2]
rels = [json.loads(line) for line in open(path) if line.strip()]
hits = [r for r in rels if r.get("tag_name") == tag]
if not hits:
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
and title.fullmatch(r.get("name") or "")]
if len(hits) != 1:
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
sys.exit("found %s for %s; expected one draft" % (why, tag))
r = hits[0]
if not r.get("draft"):
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
missing=""
for name in $expected; do
digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); n=sys.argv[2]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
d=json.load(sys.stdin); n=sys.argv[1]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
@@ -72,32 +30,16 @@ done
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
import json, sys
d = json.load(open(sys.argv[1]))
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
d=json.load(sys.stdin)
names=set(sys.argv[1].split())
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
for a in d["assets"] if a["name"] in names]}, indent=1))
EOF
old=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1]))
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
if [ -n "$dry" ]; then
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
cat "$tmp/update.json"
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
exit 0
fi
for asset in $old; do
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
done
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
echo "running copies will offer $tag at their next check"
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
echo "published $tag; running copies will offer it at their next check"
-77
View File
@@ -1,77 +0,0 @@
#!/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')
+161
View File
@@ -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
-87
View File
@@ -1,87 +0,0 @@
"""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()
-471
View File
@@ -1,471 +0,0 @@
// 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(){},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'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
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');
});
+4 -27
View File
@@ -10,7 +10,6 @@ import atexit
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
@@ -23,22 +22,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
# 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'
ENABLED = os.environ.get('FRAME_E2E') == '1'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
@@ -46,14 +30,9 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
"""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)')
"""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)')
# ---- the fake Frame's control port --------------------------------------------
@@ -118,7 +97,6 @@ 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:
@@ -215,7 +193,6 @@ class FrameTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
require()
Server.start()
def setUp(self):
-2
View File
@@ -6,8 +6,6 @@ import sys
import harness
from harness import api, ok, finished, ssh
harness.require()
sys.path.insert(0, str(harness.ROOT / 'ui'))
import frame_mcp
+1 -1
View File
@@ -5,7 +5,7 @@ import unittest
import harness
from harness import HOME, exists, ok, state, upload, wait_for
from apk_fixtures import apk, manifest, resources
from test_frame_apk import apk, manifest, resources
harness.require()
-100
View File
@@ -1,100 +0,0 @@
#!/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))
-1
View File
@@ -43,7 +43,6 @@ 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
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
Anything else fails, so the script under test can't fall back to `gh release ...`
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import os
import shutil
import subprocess
import sys
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
if not args or args[0] != "api":
sys.exit("fakegh: only `gh api` is supported: %r" % args)
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
while i < len(args):
a = args[i]
if a == "-X":
method = args[i + 1]; i += 2
elif a in ("-f", "-F"):
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
elif a == "--input":
inp = args[i + 1]; i += 2
elif a == "--jq":
jq = args[i + 1]; i += 2
elif a == "-H":
i += 2
elif a.startswith("-"):
i += 1
elif endpoint is None:
endpoint = a; i += 1
else:
sys.exit("fakegh: unexpected argument %r" % a)
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
def answer(response, fallback):
"""Print response through --jq when jq is here, else the fixed fallback text."""
if jq and shutil.which("jq"):
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
sys.stdout.write(out.stdout)
if out.returncode:
sys.exit("fakegh: jq failed: " + out.stderr)
else:
print(fallback)
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
elif method == "POST" and endpoint == repo + "releases":
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
"name": fields.get("name"), "draft": fields.get("draft") == "true",
"body": fields.get("body"), "assets": [],
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
dst.write(src.read())
print("{}")
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
sys.exit("gh: Not Found (HTTP 404)")
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
"published %s at %s" % (fields.get("tag_name"), page))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
+3 -96
View File
@@ -3,20 +3,10 @@
each step of a connection, and plays a ControlMaster. What each HostName does comes
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
"slow" (hangs after connecting);
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.
With $FAKESSH_PINS set, host keys are checked as OpenSSH does against UserKnownHostsFile under
HostKeyAlias: "wrong" is then just a different key from the others' (a reinstalled headset),
StrictHostKeyChecking=yes refuses a key not saved, accept-new saves a first one, and a saved key
that differs is refused. "evil" is a device with its own key that lets anyone in without
authentication ("none") and asks for the agent: it records in $FAKESSH_DIR what it got
(agent-forwarded, command-ran). Like ssh, the first value of an option counts."""
import base64
import hashlib
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
import json
import os
import signal
import subprocess
import sys
import time
@@ -27,12 +17,12 @@ hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
opts = {}
i = 0
while i < len(args) and args[i].startswith("-"):
if args[i] in ("-o", "-O", "-p", "-l", "-F", "-W"):
if args[i] in ("-o", "-O", "-p", "-l"):
key = args[i]
val = args[i + 1]
if key == "-o":
k, _, v = val.partition("=")
opts.setdefault(k.lower(), v)
opts[k.lower()] = v
else:
opts[key] = val
i += 2
@@ -40,12 +30,6 @@ while i < len(args) and args[i].startswith("-"):
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
command = args[i + 1:]
if opts.get("-F") != "none":
# $FAKESSH_CONFIG: what ~/.ssh/config says (JSON, lower-case option -> value), below the
# command line's options, as ssh has it; `-F none` reads no config.
for k, v in json.loads(os.environ.get("FAKESSH_CONFIG", "{}")).items():
opts.setdefault(k, v)
host = opts.get("hostname", alias).replace("%%", "%")
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
@@ -60,23 +44,6 @@ if opts.get("-O") == "exit":
os.unlink(marker)
sys.exit(0)
if "-W" in opts: # a jump host's ssh, forwarding to the headset: the call is logged, that's all
sys.exit(0)
if opts.get("proxycommand", "none") != "none":
# As OpenSSH: expand %-tokens, then `$SHELL -c "exec <line>"` (here /bin/sh).
line, out, n = opts["proxycommand"], "", 0
while n < len(line):
if line[n] == "%" and n + 1 < len(line):
out += {"%": "%", "h": host, "p": opts.get("port", "22"), "r": opts.get("user", "")}.get(
line[n + 1], line[n:n + 2])
n += 2
else:
out += line[n]
n += 1
if subprocess.run(["/bin/sh", "-c", "exec " + out], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL).returncode:
say("kex_exchange_identification: Connection closed by remote host")
sys.exit(255)
what = hosts.get(host)
if what is None:
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
@@ -86,66 +53,6 @@ say("debug1: Connection established.")
if what == "slow":
time.sleep(30)
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhostsfile" in opts:
name, path = opts["hostkeyalias"], os.path.expanduser(opts["userknownhostsfile"])
blob = base64.b64encode(b"fake-" + {"wrong": b"other", "evil": b"evil"}.get(what, b"headset") + b"-key").decode()
fp = base64.b64encode(hashlib.sha256(base64.b64decode(blob)).digest()).decode().rstrip("=")
say(f"debug1: Server host key: ssh-ed25519 SHA256:{fp}")
try:
saved = [ln.split() for ln in open(path, errors="replace").read().splitlines() if ln.strip()]
except OSError:
saved = []
saved = [f[2] for f in saved if len(f) >= 3 and name in f[0].split(",")]
strict = opts.get("stricthostkeychecking", "ask")
if saved and blob not in saved:
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
say("Host key verification failed.")
sys.exit(255)
if not saved and strict != "accept-new":
say(f"No ED25519 host key is known for {name} and you have requested strict checking.")
say("Host key verification failed.")
sys.exit(255)
if not saved:
try:
with open(path, "a") as f:
f.write(f"{name} ssh-ed25519 {blob}\n")
say(f"Warning: Permanently added '{name}' (ED25519) to the list of known hosts.")
except OSError: # ssh doesn't create the folder: it goes on, saving nothing
say(f"Failed to add the host to the list of known hosts ({path}).")
else:
say(f"debug1: Host '{name}' is known and matches the ED25519 host key.")
say("debug1: Next authentication method: publickey")
if what == "evil":
say(f'Authenticated to {host} ([127.0.0.1]:22) using "none".')
got = os.path.join(os.environ["FAKESSH_DIR"], "evil-")
if opts.get("forwardagent", "no") != "no" and opts.get("identityagent", "") != "none":
open(got + "agent-forwarded", "w").close()
if "-N" not in opts and (command or opts.get("remotecommand", "none") != "none"):
open(got + "command-ran", "w").close()
sys.exit(0)
time.sleep(30) # -N: holds the session open
sys.exit(0)
if opts.get("preferredauthentications") == "none":
say(f"tester@{host}: Permission denied (publickey,password).")
sys.exit(255)
if what == "denied":
say(f"tester@{host}: Permission denied (publickey).")
sys.exit(255)
what = "ok"
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
if opts.get("controlmaster") != "yes":
sys.exit(0)
open(marker, "w").close()
def bye(*_):
if os.path.exists(marker):
os.unlink(marker)
sys.exit(0)
signal.signal(signal.SIGTERM, bye)
while True:
time.sleep(0.2)
if not os.path.exists(marker):
sys.exit(0)
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
if what == "wrong":
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
-82
View File
@@ -1,82 +0,0 @@
// The live view's Desktop panel list (#73), run in node against the real functions from
// ui/index.html with a fake server: the headset's reason when it lists no panels, a failed
// listing shown (first time too) rather than "Finding…", and recovery after it.
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const grab = name => {
const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
if (!m) throw new Error("not found: " + name);
return m[0];
};
// The page's scripts share one global scope, so a second loadPanels would silently replace
// the Desktop view's (what broke #73 until #84): the one grabbed here must be the only one.
for (const name of ["loadPanels", "noPanelWhy", "deskPanel", "ctrlTarget"]) {
const count = (src.match(new RegExp(`\\n(?:async )?function ${name}\\(`, "g")) || []).length;
if (count !== 1) { console.log(`FAIL ${name} is defined ${count} times`); process.exit(1); }
}
const code = ["desk", "panelKey", "noPanelWhy", "deskPanel", "loadPanels", "ctrlTarget"].map(grab).join("");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function page(answer) {
const calls = [], logged = [];
const env = {
view: "flat", live: false, ctrlMode: () => "abs", renderPanelPick: () => {}, toggleLive: () => {}, ctrlShow: () => {},
log: (m) => logged.push(m),
api: async (path, body) => { calls.push([path, body]); return answer(); },
};
const fns = new Function(...Object.keys(env), `${code}
return { desk, noPanelWhy, loadPanels, ctrlTarget };`)(...Object.values(env));
return { ...fns, calls, logged };
}
// The Desktop view asks for the headset's panels with a plain GET (no switcher action).
{
const p = page(() => ({ panels: [], focus: null, why: "The headset's desktop is running, but SteamOS isn't listing it as a panel." }));
if (p.ctrlTarget().why !== "Finding the headset's panels…") fail("before loading: " + p.ctrlTarget().why);
await p.loadPanels();
if (!(p.calls.length === 1 && p.calls[0][0] === "/api/panels" && p.calls[0][1] === undefined))
fail("request " + JSON.stringify(p.calls));
if (!p.desk.loaded) fail("not marked loaded");
if (!p.noPanelWhy().startsWith("The headset's desktop is running")) fail("why for capture: " + p.noPanelWhy());
if (!p.ctrlTarget().why.startsWith("The headset's desktop is running")) fail("why for control: " + p.ctrlTarget().why);
}
// An older headset script without "why": the generic advice.
{
const p = page(() => ({ panels: [], focus: null }));
await p.loadPanels();
if (!p.noPanelWhy().startsWith("No app panel is open in the headset. Open the desktop")) fail("generic: " + p.noPanelWhy());
}
// The first listing fails: Control and Capture say so (logged once), then recover.
{
let broken = true;
const p = page(() => {
if (broken) throw new Error("Timed out talking to frame");
return { panels: [{ window: 7, display: ":0", name: "Desktop" }], focus: 7, focus_display: ":0" };
});
await p.loadPanels(); await p.loadPanels();
const why = p.ctrlTarget().why;
if (why !== "Couldn't list the headset's panels: Timed out talking to frame") fail("initial failure in Control: " + why);
if (p.noPanelWhy() !== why) fail("initial failure in Capture: " + p.noPanelWhy());
if (p.logged.length !== 1) fail("logged " + p.logged.length + " times");
broken = false;
await p.loadPanels();
if (p.desk.error) fail("error kept after recovery");
const t = p.ctrlTarget();
if (!t.why || t.why.includes("Couldn't list")) fail("after recovery: " + JSON.stringify(t));
if (p.desk.panels.length !== 1) fail("panels after recovery");
}
// Waiting for the Frame: shown in Control, not logged every poll. An answer about the
// previous headset (stale) is ignored.
{
let err;
const p = page(() => { throw err; });
err = Object.assign(new Error("Can't reach the Frame"), { offline: true });
await p.loadPanels(); await p.loadPanels();
if (p.logged.length) fail("offline logged: " + p.logged);
if (p.ctrlTarget().why !== "Couldn't list the headset's panels: Can't reach the Frame") fail("offline: " + p.ctrlTarget().why);
const q = page(() => { throw Object.assign(new Error("old headset"), { offline: true, stale: true }); });
await q.loadPanels();
if (q.logged.length || q.desk.error) fail("stale answer used: " + q.desk.error);
if (q.ctrlTarget().why !== "Finding the headset's panels…") fail("stale: " + q.ctrlTarget().why);
}
console.log("ok");
-43
View File
@@ -1,43 +0,0 @@
// 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&#39;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");
-93
View File
@@ -1,93 +0,0 @@
// The Android store's app page, run in node against the real functions from ui/index.html.
// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the
// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now.
// (tests/test_server.py PageScripts checks no top-level name is declared twice.)
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); };
// A top-level declaration, from its first line to where its braces balance.
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 names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
// #detailBody holds the real app page, as in the browser.
function page(api = async () => ({})) {
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
: (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] });
const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() };
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); });
return { els, sourceState, calls, ...fns };
}
const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR",
description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush",
artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } };
// An app from the catalogue: its page renders, with its name, install and sources.
{
const p = page();
p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("a good listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200));
if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel");
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
p.sourceState.detail = { app: { name: "Broken <App>", offers: [] }, choice: 0 };
if (p.renderAppDetail() !== false) fail("a broken listing reported success");
const html = p.els.detailBody.innerHTML;
if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken &lt;App&gt;")) fail("no error state: " + html);
if (!html.includes('data-close="appDetail"')) fail("error state has no way to close");
}
// Opening a listing that can't render: the error stays up, no details request, nothing thrown.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Broken", offers: [] }];
await p.openStoreApp(0);
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");
}
// Opening a good listing: the dialog opens with its page, and fresher details fill it in.
{
const p = page(async () => ({ description: "Fresh description." }));
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes("Fresh description.")) fail("details didn't fill in the page");
if (!String(p.calls[0]?.[0]).startsWith("/api/sources/details?")) fail("no details request: " + JSON.stringify(p.calls));
}
// A good listing whose details request fails keeps its page, with a note.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
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");
}
console.log("ok");
+1 -31
View File
@@ -1,20 +1,12 @@
"""Imported first by every test module: nothing a test does reaches this person's
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
app data, 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)
@@ -22,25 +14,3 @@ 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
View File
@@ -1,5 +1,4 @@
"""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
View File
@@ -1,4 +1,3 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
-1
View File
@@ -1,4 +1,3 @@
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
View File
@@ -1,4 +1,3 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
-45
View File
@@ -1,5 +1,4 @@
"""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
@@ -256,47 +255,3 @@ 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)
-30
View File
@@ -1,5 +1,4 @@
"""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
@@ -59,32 +58,3 @@ 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_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)
-412
View File
@@ -1,412 +0,0 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_only_a_real_true_counts_as_consent(self):
for wrong in ("false", "true", 1, 0, [], {}):
with self.assertRaisesRegex(ValueError, "true or false"):
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
with self.assertRaisesRegex(ValueError, "true or false"):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
self.assertEqual((fc.load()["email"], self.got), ("", []))
fc.save({"email": "me@example.com", "updates": True}) # left out is no
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def reports(self):
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = self.reports()
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
consent = [e for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
self.assertEqual(self.reports()[1]["contact_rev"], 1)
fc.save({"email": ""}) # Remove my email
last = self.events()[-1]
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
("withdraw", "", 2))
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>", "<removed>"])
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
"""Update notices were agreed for the old address, not the new one (the form says so)."""
fc.save({"email": "old@example.com", "updates": True})
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
self.assertEqual(self.reports()[0]["contact_rev"], 2)
fc.save({"email": "new@example.com", "updates": True, "followup": False})
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
"""Removed while the report's own consent is on its way: the report keeps that consent's
rev (so the removal is newer) and is logged without the address."""
post, removed = tm.post, []
def slow_post(events, **kw):
post(events, **kw)
if not removed and events[0]["event"] == "contact_consent":
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
with mock.patch.object(tm, "post", side_effect=slow_post):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
report = self.reports()[0]
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>"])
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
"0.4.0", "Windows", "", "", followup, cid, rev]
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
reports = [self.report_row(), # removed later
self.report_row(cid="other"), # another copy, still agrees
self.report_row(rev=3), # sent after the removal
self.report_row(cid="moved"), # address changed later
self.report_row(cid="news-only"), # follow-up unticked later
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
self.report_row(cid="", followup=True), # no contact id: left alone
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports],
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
def test_the_change_number_decides_not_the_clock(self):
"""The clock went back between the report and the removal: the removal still counts."""
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
fc.save({"email": ""})
report = self.reports()[0]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
reports = [self.report_row(), ["short"]]
consents = [["copy", "", False, 2]]
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
mock.patch("builtins.print") as out:
fr.main()
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("follow-up permission since withdrawn", printed)
self.assertNotIn("me@example.com", printed)
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
fr.inbox()
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
def test_saving_is_not_headset_work(self):
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
import io
import server
seen = []
for path in ("/api/contact", "/api/contact/prompt"):
h = server.Handler.__new__(server.Handler)
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
h.path, h.rfile = path, io.BytesIO(body)
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
h.local_request = lambda: True
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
h.do_POST()
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
-75
View File
@@ -1,75 +0,0 @@
"""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}])
+2 -29
View File
@@ -17,7 +17,6 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
@@ -264,30 +263,6 @@ class Pins(Base):
self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("d1"))
def test_a_trusted_key_replaces_the_saved_one_and_fingerprints_match_ssh_keygen(self):
key = self.dir / "hostkey"
frame_host.run_ssh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=20)
kind, blob = key.with_suffix(".pub").read_text().split()[:2]
want = frame_host.run_ssh(["ssh-keygen", "-l", "-E", "sha256", "-f", str(key.with_suffix(".pub"))],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=10).stdout.split()[1]
self.assertEqual(fd.fingerprint(blob), want)
self.assertIsNone(fd.fingerprint("not base64!"))
(self.ssh / "known_hosts").write_text(f"frame.local {KEY}\n")
fd.seed_pin("d1", ["frame.local"])
old = fd.pinned_keys("d1")
fd.pin_key("d1", kind, blob)
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {kind} {blob}\n") # old key gone
self.assertEqual(fd.pinned_keys("d1"), [(kind, want)])
self.assertNotEqual(old, fd.pinned_keys("d1"))
self.assertTrue(fd.pinned("d1"))
for bad in (("ssh-ed25519\nHost *", blob), ("ssh-ed25519", "AAAA AAAA"), ("evil", blob)):
with self.assertRaises(fd.DeviceError):
fd.pin_key("d1", *bad)
# Only plain entries for this headset's alias count.
self.assertIsNone(fd.key_entry(f"frame-control-d2 {kind} {blob}", "frame-control-d1"))
self.assertIsNone(fd.key_entry(f"# frame-control-d1 {kind} {blob}", "frame-control-d1"))
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
@@ -299,8 +274,7 @@ class Pins(Base):
def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
@@ -309,8 +283,7 @@ class Pins(Base):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
-31
View File
@@ -1,5 +1,4 @@
"""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
@@ -257,36 +256,6 @@ class Repositories(unittest.TestCase):
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
def test_per_abi_builds_offer_and_download_the_arm64_one(self):
import hashlib
def build(code, name, abis):
self.files[name] = name.encode()
return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
'nativecode': abis},
'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
'added': 0}
# One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
# plus a universal and an arm64-only build sharing a code.
builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
build(3913, 'app-x86.apk', ['x86']),
build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
raw = self.root / 'splits.json'
raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
'metadata': {'name': {'en-US': 'Grayjay'}},
'versions': {str(i): b for i, b in enumerate(builds)}}}}))
source = {'id': 'test', 'url': URL}
app = fdroid._reduce(raw, source)['com.futo.platformplayer']
self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
with patch.object(fdroid, 'details', return_value=app):
for code in (None, 3912):
fdroid.download(source, 'com.futo.platformplayer', version_code=code)
self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
with self.assertRaises(SourceError): # the x86_64 build is never offered
fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
+1 -2
View File
@@ -1,4 +1,3 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
@@ -64,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
+2 -62
View File
@@ -1,5 +1,4 @@
"""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
@@ -264,14 +263,11 @@ class ArtworkTests(unittest.TestCase):
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
import frame_steamgriddb as sgdb
from apk_sources import _images
deadline = time.monotonic() + 5
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
self.assertEqual(sgdb._get('/search/x', 'secret', deadline), [1])
self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
self.assertEqual(get.call_args.kwargs['redirects'], 0)
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
# The caller's deadline is passed on, never extended. (Comparing against a fresh
# time.monotonic() flaked on Windows, whose clock ticks coarsely.)
self.assertLessEqual(get.call_args.kwargs['deadline'], deadline)
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
@@ -423,19 +419,6 @@ 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'), \
@@ -499,49 +482,6 @@ 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:
+76 -15
View File
@@ -11,7 +11,82 @@ 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
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
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()
class ApkInfo(unittest.TestCase):
@@ -93,20 +168,6 @@ 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()
-29
View File
@@ -184,35 +184,6 @@ class VersionsTest(unittest.TestCase):
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_wrong_abi_error_says_which_file_to_get(self):
import frame_telemetry
for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried
info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis}
with self.assertRaises(frame_android.FrameError) as error:
frame_android.check_installable(info)
message = str(error.exception)
self.assertIn('no arm64-v8a build (%s)' % abis[0], message)
self.assertIn('download the APK marked arm64-v8a', message)
self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi')
frame_android.check_installable({'label': 'Universal', 'min_sdk': 28,
'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']})
def test_wrong_file_reports_do_not_rate_the_app(self):
reports = frame_catalog.reports
wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed',
'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only',
'date': '2026-10-01T10:00:00'}
self.assertIsNone(reports.verdict([wrong_file]))
app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file])
self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands
# A real installer failure, a crash or a person's rating still counts.
installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK')
self.assertEqual(reports.verdict([installer])[0], 'no')
crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02')
self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no')
rated = dict(wrong_file, rating='works', date='2026-10-03')
self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works')
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
+2 -81
View File
@@ -1,5 +1,4 @@
"""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
@@ -16,7 +15,7 @@ import frame_apk
import frame_apk_vr as vr
import frame_apk_sign as signing
import frame_android
from apk_fixtures import pool
from test_frame_apk import pool
DEFAULT = 'android.intent.category.DEFAULT'
@@ -256,10 +255,6 @@ class VRTests(unittest.TestCase):
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
# The library verified on the headset (its SHA-256 is in the layer's README).
import hashlib
digest = hashlib.sha256(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so']).hexdigest()
self.assertIn(digest, (Path(frame_android.XR_COMPAT) / 'README.md').read_text())
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
@@ -274,82 +269,8 @@ class VRTests(unittest.TestCase):
with zipfile.ZipFile(apk, 'w') as z:
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaisesRegex(frame_android.LayerMissing, 'build.sh'):
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
frame_android.xr_compat_files(str(apk))
# Present but corrupt, or empty, counts as missing too.
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
with open(os.path.join(d, rel), 'wb') as f:
f.write(b'\x1f\x8b not really gzip')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
open(os.path.join(d, rel), 'wb').close()
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
def test_layer_ships_in_packaged_builds(self):
"""The arm64 library is in the repo, copied by electron-builder, and checked after packing."""
import fnmatch, json
root = Path(__file__).resolve().parents[1]
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertGreater((root / 'frame/openxr-compat' / rel).stat().st_size, 0, rel)
# No bare arm64 ELF ships: electron-builder's 7-Zip gives those an ARM64 filter the
# Windows installer can't extract, so the installed app silently lacked the library.
for f in (root / 'frame/openxr-compat/prebuilt').rglob('*'):
if f.is_file():
self.assertNotEqual(f.read_bytes()[:4], b'\x7fELF', f)
build = json.loads((root / 'app/package.json').read_text())['build']
self.assertEqual(build.get('afterPack'), 'build/check-resources.js')
entry = next(e for e in build['extraResources'] if e['from'] == '../frame/openxr-compat')
self.assertEqual(entry['to'], 'frame/openxr-compat')
check = (root / 'app/build/check-resources.js').read_text()
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertTrue(any(fnmatch.fnmatch(rel, f.replace('**/', '*/')) for f in entry['filter']), rel)
self.assertFalse(any(f.endswith('.so') for f in entry['filter']))
self.assertIn('frame/openxr-compat/' + rel, check)
def test_install_goes_ahead_without_a_missing_layer(self):
"""A copy of Frame Control without the layer installs VR apps anyway and says so."""
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, 'vr_issues': [],
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
missing = frame_android.LayerMissing(frame_android.LAYER_MISSING)
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', side_effect=missing), \
patch.object(frame_android, 'patch') as repair, \
patch.object(frame_android, '_install', return_value={'package': 'org.test.vr'}) as install, \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
frame_android.install('game.apk')
repair.assert_not_called() # nothing to add and nothing to repair: the APK goes as is
info = install.call_args.args[1]
self.assertTrue(info['xr_layer_missing'])
self.assertEqual(info['vr_issues'], [frame_android.LAYER_MISSING_NOTE])
self.assertIsNone(seen[-1][2]) # reported as a working install
# Asked for explicitly, a missing layer is still an error.
with self.assertRaises(frame_android.LayerMissing):
frame_android.install('game.apk', xr_compat=True)
self.assertIsInstance(seen[-1][2], frame_android.LayerMissing)
def test_patch_failures_are_named_and_every_failure_is_reported(self):
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', return_value={'x': b'1'}), \
patch.object(frame_android, 'patch', side_effect=frame_android.FrameError('ZIP64 APKs are unsupported')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaisesRegex(frame_android.FrameError, 'could not prepare the APK for the Frame: ZIP64'):
frame_android.install('game.apk')
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base, vr=False)), \
patch.object(frame_android, '_install', side_effect=FileNotFoundError(2, 'No such file or directory', 'ssh')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaises(FileNotFoundError):
frame_android.install('game.apk')
self.assertIsInstance(seen[-1][2], FileNotFoundError) # not only FrameErrors reach telemetry
def test_patch_rejects_corrupt_manifest_cleanly(self):
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
-87
View File
@@ -1,87 +0,0 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
-1
View File
@@ -6,7 +6,6 @@ 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
-127
View File
@@ -1,127 +0,0 @@
"""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
View File
@@ -1,5 +1,4 @@
"""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
+5 -699
View File
@@ -6,10 +6,8 @@ Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import io
import json
import os
import shlex
import shutil
import socket
import subprocess
@@ -46,17 +44,6 @@ 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:
@@ -211,8 +198,7 @@ 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")
# 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")
self.assertIn("different headset", rows["::1"]["detail"])
# 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")
@@ -221,10 +207,9 @@ class Connecting(unittest.TestCase):
self.assertIn(f"Port={self.port}", opts)
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
self.assertNotIn("StrictHostKeyChecking=yes", master)
# This stand-in ssh saved no key, so commands may still save the first one (accept-new);
# test_a_first_connection_pins_and_then_checks_strictly has real pins.
self.assertIn("StrictHostKeyChecking=accept-new", opts)
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
# It learned: 127.0.0.1 works on this network.
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
@@ -271,9 +256,6 @@ class Connecting(unittest.TestCase):
s = self.link.snapshot()
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")
@@ -283,428 +265,6 @@ class Connecting(unittest.TestCase):
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=yes", master)
def pins(self):
"""The stand-in ssh checks host keys as OpenSSH does (tests/fakessh/ssh)."""
p = mock.patch.dict(os.environ, {"FAKESSH_PINS": "1"})
p.start()
self.addCleanup(p.stop)
def command(self, device, host="127.0.0.1"):
"""One ssh command as the server runs it, with the route the connector applied."""
return subprocess.run(["ssh", *self.link.host_opts(device, host), device["alias"], "true"],
capture_output=True, text=True, timeout=20)
def test_a_first_connection_pins_and_then_checks_strictly(self):
# 0.4.2 on Windows: "No ED25519 host key is known for frame-control-<id> and you have
# requested strict checking." Every command said "yes" before anything was saved, so one
# that ran before the connector's handshake saved the key (or without it) failed.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
r = self.command(d)
self.assertEqual(r.returncode, 0, r.stderr) # the first contact saves the key...
self.assertIn("Permanently added", r.stderr)
self.assertTrue(fd.pinned(d["id"]))
self.assertIn("StrictHostKeyChecking=yes", self.link.host_opts(d, "127.0.0.1")) # ...then it's checked
self.assertEqual(self.reg.get(d["id"])["identity"], [k[1] for k in fd.pinned_keys(d["id"])]) # and recorded
# The connector's first handshake, for another new headset.
self.reg.remove_device(d["id"])
d = self.device("localhost")
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(fd.pinned(d["id"]))
self.assertTrue(self.reg.get(d["id"])["identity"])
alias, opts = self.routes[-1]
self.assertIn("StrictHostKeyChecking=yes", opts) # every command after it checks strictly
self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
def lose_pin(self, device_id, how):
f = fd.known_hosts(device_id)
if how == "deleted":
f.unlink()
elif how == "empty":
f.write_text("")
elif how == "unreadable":
f.chmod(0)
self.addCleanup(lambda: f.exists() and f.chmod(0o600))
elif how == "another alias":
f.write_text(f.read_text().replace(f"frame-control-{device_id}", "frame-control-someone-else"))
elif how == "not utf-8":
f.write_bytes(b"\xff\xfe" + f.read_bytes())
def test_a_lost_or_unusable_pin_fails_closed(self):
# Review pr85-review-r1: accept-new can't tell a new headset from one whose saved key
# went missing; real OpenSSH then trusted a replacement key. Once a headset had an
# identity, nothing is trusted again until the person checks it.
self.pins()
cases = ["deleted", "empty", "another alias", "not utf-8"]
if os.name == "posix" and os.geteuid() != 0:
cases.append("unreadable") # root reads anything
for n, how in enumerate(cases):
with self.subTest(how=how):
self.hosts({"localhost": "ok"})
d = self.reg.add_device(f"frame-l{n}", port=self.port, hosts=[])
self.reg.add_address(d["id"], "localhost", kind="lan")
self.link.override = None
self.reg.set_active(d["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.link.close_master()
d = self.reg.get(d["id"])
self.lose_pin(d["id"], how)
self.hosts({"localhost": "wrong"}) # another device, or a reinstalled headset
r = self.command(d)
self.assertEqual(r.returncode, 255, r.stderr)
self.assertIn("Host key verification failed", r.stderr)
self.assertNotIn("Permanently added", r.stderr)
self.link.connect(["dropped"])
s = self.link.snapshot()
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"]
self.assertFalse(scan["matches"] or scan["recorded_match"]) # not the key it had
self.hosts({"localhost": "ok"})
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertTrue(scan["recorded_match"]) # its own key, as recorded
self.assertFalse(scan["matches"]) # but not usable from the file: trusting restores it
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
self.link.close_master()
def test_a_key_check_only_reads_the_key(self):
# Review pr85-review-r1: with ForwardAgent yes in ~/.ssh/config, a device that accepts
# "none" got the agent and ran a command before anyone trusted it.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "evil"})
os.environ["FAKESSH_CONFIG"] = json.dumps({"forwardagent": "yes", "remotecommand": "touch /tmp/x",
"permitlocalcommand": "yes", "requesttty": "force"})
self.addCleanup(os.environ.pop, "FAKESSH_CONFIG", None)
for proxied in (False, True):
route = ("jump", "me@jumpy:2222") if proxied else (None, None)
with self.subTest(proxied=proxied), mock.patch.object(fl, "proxy_route", return_value=route):
t0 = time.monotonic()
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertLess(time.monotonic() - t0, 10) # stopped once it had the key, not left open
self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
self.assertEqual([p.name for p in self.dir.iterdir() if p.name.startswith("evil-")], [])
call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1]
self.assertEqual(call[call.index("-F") + 1], "none") # never ~/.ssh/config for the device
self.assertIn("-N", call)
hop = [c for c in call if c.startswith("ProxyCommand=")]
if proxied:
# Review pr85-review-r2: ProxyJump's own ssh inherits the config (ForwardAgent,
# LocalCommand); the scan builds the jump host's ssh itself, hardened.
self.assertEqual(len(hop), 1)
words = shlex.split(hop[0][len("ProxyCommand="):])
self.assertEqual(words[-7:], ["-l", "me", "-p", "2222", "-W", "[%h]:%p", "jumpy"])
for o in fl.Link.HOP_OPTS:
self.assertIn(o, words)
self.assertLess(call.index(hop[0]), call.index("HostKeyAlias=frame-control-" + d["id"]))
else:
self.assertEqual(hop, [])
for o in ("ForwardAgent=no", "IdentityAgent=none", "ClearAllForwardings=yes", "RemoteCommand=none",
"PermitLocalCommand=no", "RequestTTY=no", "PubkeyAuthentication=no"):
self.assertIn(o, call)
self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("HostKeyAlias=frame-control-"
+ d["id"]))
self.assertFalse(fd.pinned(d["id"]))
self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), []) # no scan files
@unittest.skipIf(os.name != "posix", "the stand-in ssh runs ProxyCommand with /bin/sh")
def test_the_jump_hosts_command_line_is_quoted_whatever_the_names(self):
# Review pr85-review-r3: the hop's words went into the ProxyCommand line unquoted, so an
# ssh at /tmp/x/bin$(:>INJECTED)/ssh ran shell code; and an IPv6 jump host broke.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
for n, name in enumerate(["with space", "bin$(touch INJECTED-0)", "back`touch INJECTED-1`tick",
"quote'it\"s", "per%cent %h %%", "semi;touch INJECTED-2"]):
with self.subTest(name=name):
bindir = self.dir / f"b{n}" / name
bindir.mkdir(parents=True)
(bindir / "ssh").symlink_to(FAKESSH / "ssh")
for spec, at in (("me@jumpy:2222", "jumpy"), ("[::1]:2200", "::1")):
before = len(self.calls())
with mock.patch.dict(os.environ, {"PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}), \
mock.patch.object(fl, "proxy_route", return_value=("jump", spec)):
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
hops = [c for c in self.calls()[before:] if "-W" in c]
self.assertEqual(len(hops), 1, self.calls()[before:])
self.assertEqual(hops[0][-1], at) # the jump host, bare
self.assertEqual(hops[0][hops[0].index("-W") + 1], "[localhost]:%d" % self.port)
outer = [c for c in self.calls()[before:] if "PreferredAuthentications=none" in c][-1]
line = next(c for c in outer if c.startswith("ProxyCommand="))
self.assertTrue(line[len("ProxyCommand="):].startswith(shlex.quote(str(bindir / "ssh")
.replace("%", "%%"))))
self.assertEqual([p.name for p in self.dir.rglob("INJECTED*")], [])
self.assertFalse(list(Path.cwd().glob("INJECTED*")))
def test_a_key_check_refuses_routes_it_cant_harden(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
before = len(self.calls())
for route in (("command", "nc %h %p"), ("jump", "a,b"), ("jump", "-oProxyCommand=x"), ("jump", "ssh://a"),
("jump", "a b"), ("jump", "a%h"), ("jump", "jumpy:99999")):
with self.subTest(route=route), mock.patch.object(fl, "proxy_route", return_value=route):
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertIn("can't make safe", str(caught.exception))
self.assertFalse(any("PreferredAuthentications=none" in c for c in self.calls()[before:]))
def test_a_key_read_before_the_address_or_port_changed_isnt_trusted(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
for change in ({"action": "update", "id": d["id"], "port": self.port + 1},
{"action": "address-remove", "id": d["id"], "host": "localhost"}):
with self.subTest(change=change["action"]):
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
fl.devices_action(self.link, change, None)
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.assertFalse(fd.pinned(d["id"]))
self.reg.update_device(d["id"], port=self.port)
if not self.reg.get(d["id"])["addresses"]:
self.reg.add_address(d["id"], "localhost", kind="lan")
def test_terminals_judge_identity_by_the_registry_not_a_frozen_route(self):
# Review pr85-review-r2: named_route used the device as routed (before its first key was
# recorded), so a Terminal window fell back to accept-new once the pin was deleted.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.assertFalse(self.link.routed_device.get("identity")) # the frozen copy knows nothing of it
fd.known_hosts(d["id"]).unlink()
alias, opts = self.link.named_route()
self.assertIn("StrictHostKeyChecking=yes", opts)
self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
self.hosts({"localhost": "wrong"})
r = subprocess.run(["ssh", *opts, alias, "true"], capture_output=True, text=True, timeout=20)
self.assertEqual(r.returncode, 255, r.stderr)
self.assertFalse(fd.pinned(d["id"]))
def test_a_key_saved_before_a_refused_login_is_kept_and_checked(self):
# Review pr85-review-r2: ssh saves the key during the key exchange; if the login is then
# refused, it was neither recorded nor protected, and the route stayed accept-new.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
got = self.reg.get(d["id"])
self.assertEqual(got["identity"], [f for _, f in fd.pinned_keys(d["id"])])
self.assertTrue(got["identity_established"])
if os.name == "posix":
self.assertEqual(fd.known_hosts(d["id"]).stat().st_mode & 0o777, 0o600)
self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
fd.known_hosts(d["id"]).unlink()
self.hosts({"localhost": "wrong"})
r = self.command(self.reg.get(d["id"]))
self.assertEqual(r.returncode, 255)
self.assertNotIn("Permanently added", r.stderr)
def test_a_key_a_command_saved_tightens_the_route(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "denied"})
with self.link.route_lock:
self.link.routed_device = d
self.link.apply(d["alias"], self.link.host_opts(d, "127.0.0.1"))
opts = self.routes[-1][1]
self.assertIn("StrictHostKeyChecking=accept-new", opts)
r = subprocess.run(["ssh", *opts, d["alias"], "true"], capture_output=True, text=True, timeout=20)
self.assertIn("Permanently added", r.stderr)
self.assertEqual(r.returncode, 255) # saved the key, then refused the login
self.link.settle_pin() # what the connector's loop does every tick while accept-new is applied
self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
self.assertNotIn("StrictHostKeyChecking=accept-new", self.routes[-1][1])
self.assertTrue(self.reg.get(d["id"])["identity"])
def test_editing_the_address_that_worked_keeps_the_identity_history(self):
# Review pr85-review-r2: last_ok lives on the address, so editing or removing it made a
# headset whose pin was gone (and had no fingerprint recorded) new again.
path = self.dir / "legacy.json"
path.write_text(json.dumps({"version": 1, "active": "abcd1234", "networks": {}, "devices": [
{"id": "abcd1234", "name": "Old", "alias": "frame-old", "user": "steamos", "port": 22,
"identity_files": [], "addresses": [{"host": "10.0.0.5", "kind": "lan", "label": "",
"networks": [], "last_ok": 1790000000.0, "last_rtt_ms": 3}]}]}))
reg = fd.Registry(path)
self.assertTrue(reg.get("abcd1234")["identity_established"]) # migrated from last_ok
reg.update_address("abcd1234", "10.0.0.5", new_host="10.0.0.6")
reg.remove_address("abcd1234", "10.0.0.6")
reg.add_address("abcd1234", "10.0.0.7")
d = fd.Registry(path).get("abcd1234") # saved, too
self.assertFalse(any(a.get("last_ok") for a in d["addresses"]))
self.assertTrue(fd.identity_established(d))
self.assertEqual(self.link.strictness(d), "yes")
# A connection that works sets it as well, for a headset added since.
n = self.reg.add_device("frame-new", hosts=["10.0.0.8"])
self.reg.record_success(n["id"], "10.0.0.8", None, 1)
self.reg.remove_address(n["id"], "10.0.0.8")
self.assertTrue(self.reg.get(n["id"])["identity_established"])
def test_ssh_last_words_arrive_even_when_the_reader_lags(self):
# Review pr85-review-r2: ssh's exit with nothing queued yet was taken as the end, and a
# late reader lost "Permission denied" ("ssh stopped").
self.pins()
link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
apply=lambda *a: None, explain=explain)
self.addCleanup(link.stop)
real = threading.Thread
class Late(real):
def __init__(self, *a, target=None, **kw):
if target is not None and getattr(target, "__name__", "") == "tail":
inner = target
def target():
time.sleep(1)
inner()
super().__init__(*a, target=target, **kw)
self.device("localhost")
self.hosts({"localhost": "denied"})
with mock.patch.object(fl.threading, "Thread", Late):
link.connect(["start"])
s = link.snapshot()
self.assertEqual((s["error"]["stage"], s["error"].get("auth")), ("login", "denied"), s["error"])
self.assertIn("SSH key", s["error"]["message"])
def test_forgetting_an_identity_is_refused(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
saved = fd.known_hosts(d["id"]).read_text()
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "forget-identity", "id": d["id"]}, None)
self.assertIn("identity-scan", str(caught.exception))
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved)
def test_without_a_shared_connection_ssh_writes_to_a_file_not_a_pipe(self):
# Windows: no ControlMaster, and its OpenSSH can block writing to a stderr pipe.
self.pins()
tmp = self.dir / "tmp"
tmp.mkdir()
link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
apply=lambda *a: None, explain=explain)
self.addCleanup(link.stop)
real = subprocess.Popen
seen = []
def popen(argv, **kw):
if "-v" in argv: # the handshake (run_ssh calls go through Popen too)
seen.append(kw.get("stderr"))
return real(argv, **kw)
with mock.patch.object(tempfile, "tempdir", str(tmp)), mock.patch.object(fl.subprocess, "Popen", popen):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
link.connect(["start"])
s = link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
self.assertIn("saved this headset's identity", next(st for st in s["stages"] if st["id"] == "identity")["detail"])
self.reg.update_device(d["id"], user="nobody")
self.hosts({"localhost": "denied"})
link.connect(["switch"])
s = link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"], s["error"].get("auth")), ("failed", "login", "denied"))
self.assertTrue(seen and all(isinstance(x, int) for x in seen)) # a file descriptor, never PIPE
self.assertNotIn(subprocess.PIPE, seen)
for _ in range(50): # the reader removes its file once ssh has exited
if not list(tmp.iterdir()):
break
time.sleep(0.05)
self.assertEqual(list(tmp.iterdir()), [])
def test_a_changed_key_is_shown_never_accepted_and_trusted_on_request(self):
# 0.4.2 on Linux and Windows: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", with
# "Run Set Up Connection again" as the advice, which can't replace the saved key.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
saved = fd.known_hosts(d["id"]).read_text()
old = fd.pinned_keys(d["id"])
self.link.close_master()
self.hosts({"localhost": "wrong"}) # SteamOS reinstalled: a new host key
self.link.connect(["dropped"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
shown = s["error"]["hostkey"]
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)
self.assertIn("REMOTE HOST IDENTIFICATION HAS CHANGED", r.stderr) # nor by any command
# Trusting needs the key read first, and exactly the fingerprint shown.
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
scan = out["scan"]
self.assertEqual(scan["fingerprint"], shown["fingerprint"])
self.assertFalse(scan["matches"])
self.assertEqual(scan["saved"], [{"type": t, "fingerprint": f} for t, f in old])
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # reading it saves nothing
self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), [d["id"]]) # no scan files
call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1] # no login offered
self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("StrictHostKeyChecking=yes"))
self.assertNotIn(f"UserKnownHostsFile={fd.known_hosts_opt(d['id'])}",
call[:call.index("StrictHostKeyChecking=yes")]) # a throwaway file, first
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:other"}, None)
self.link.scans[d["id"]]["at"] -= fl.SCAN_TTL + 1
with self.assertRaises(fd.DeviceError): # shown too long ago
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
with mock.patch.object(self.link, "kick") as kick:
out = fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
kick.assert_called_with("switch")
self.assertEqual(fd.pinned_keys(d["id"]), [("ssh-ed25519", shown["fingerprint"])]) # the old key is gone
self.assertEqual(next(x for x in out["devices"] if x["id"] == d["id"])["keys"],
[{"type": "ssh-ed25519", "fingerprint": shown["fingerprint"]}])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertTrue(out["scan"]["matches"])
def test_test_now_never_saves_a_key(self):
self.pins()
d = self.device("127.0.0.1")
self.hosts({"127.0.0.1": "ok"})
self.link.test(d["id"])
rows = self.link.snapshot()["tests"][d["id"]]["rows"]
self.assertEqual(rows[0]["ssh"], "unpinned")
self.assertFalse(fd.pinned(d["id"]))
def test_reading_a_key_from_a_headset_that_doesnt_answer_says_why(self):
self.pins()
d = self.device("nothing.invalid")
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertIn("Can't find the Frame", str(caught.exception))
self.assertEqual([p.name for p in fd.known_hosts(d["id"]).parent.iterdir()], [])
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
@@ -940,7 +500,7 @@ class Connecting(unittest.TestCase):
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
{"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:x"}):
{"action": "forget-identity", "id": d["id"]}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
@@ -1039,260 +599,6 @@ class Connecting(unittest.TestCase):
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
# ---- what a failure leaves for a problem report ----
def stderr(self):
return quiet_log(self)
def report(self):
import frame_report as fr
done = threading.Thread(target=lambda: None)
done.start()
done.join()
with mock.patch.object(fr, "link", self.link), \
mock.patch.dict(fr._ssh, {"thread": done, "line": "SSH: system OpenSSH, OpenSSH 9.9p1, LibreSSL 3.3.6"}):
return fr.diagnostics()
def test_each_failure_goes_to_the_server_log_scrubbed(self):
err = self.stderr()
self.device("steamdeck-jane.invalid", "localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
line = err.getvalue()
self.assertIn("frame_link: login failed: The Frame didn't accept this computer's SSH key.", line)
self.assertIn("addresses: hostname unresolved; hostname->ipv4", line)
for leaked in ("steamdeck-jane", "127.0.0.1", "localhost"):
self.assertNotIn(leaked, line)
self.assertEqual(self.link.last_failure["stage"], "login")
def test_reports_carry_a_connection_summary_in_fixed_words(self):
self.stderr()
self.device("steamdeck-jane.invalid", "frame-t.invalid")
(self.dir / "ssh" / "config").write_text(
f"{fd.begin_mark('frame-t')}\nHost frame-t\n HostName 192.168.1.50\n User steamos\n{fd.end_mark('frame-t')}\n"
"Host frame-t\n HostName 10.0.0.7\n")
self.link.connect(["start"])
text = self.report()
self.assertIn("Connection: failed at find, attempt 1 (Starting up)", text)
self.assertIn("Headsets: 1 saved; active alias custom (saved, 2 address(es))", text)
self.assertIn("Error: find, frame_not_set_up", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, 0 min \d+ s ago")
self.assertIn("Addresses tried: hostname unresolved; hostname unresolved", text)
self.assertIn("Network: gateway yes, Tailscale not installed", text)
self.assertIn("SSH: system OpenSSH, OpenSSH 9.9p1", text)
self.assertIn("~/.ssh/config: managed block for the active alias yes (1 managed in all); "
"hand-written Host for it yes", text)
# No free text from the error or ssh at all, and not the custom alias.
for leaked in ("steamdeck-jane", "Could not resolve", "Can't find", "192.168", "10.0.0.7", "steamos",
"frame-t", str(self.dir)):
self.assertNotIn(leaked, text)
def test_a_failure_on_the_last_headset_leaves_nothing_of_it_after_switching(self):
self.stderr()
a = self.device("steamdeck-jane.invalid")
self.link.connect(["start"])
b = self.reg.add_device("frame-2", port=self.port, hosts=[])
self.reg.add_address(b["id"], "localhost", kind="lan")
self.hosts({"localhost": "ok"})
self.reg.set_active(b["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
text = self.report()
self.assertIn("Connection: connected via hostname->ipv4", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, ")
self.assertNotIn("steamdeck-jane", text)
self.assertNotEqual(a["id"], b["id"])
def test_the_headsets_alias_and_name_stay_out_of_the_log(self):
import frame_report as fr
err = self.stderr()
d = self.reg.add_device("jane-office.example.com", name="Jane Doe's work headset", hosts=[])
self.reg.set_active(d["id"])
self.link.connect(["start"])
self.assertIn("find failed: The active headset has no addresses.", err.getvalue())
# A message that names it anyway (any case) goes too, in the log and so in a report's log.
self.link.note_failure("ssh", "JANE-OFFICE.EXAMPLE.COM: jane doe's work headset stopped answering")
log = self.dir / "server.log"
log.write_text(err.getvalue())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
with mock.patch.object(fr, "link", self.link), mock.patch.dict(fr._ssh, {"thread": None, "line": "SSH: x"}):
text = fr.diagnostics(include_logs=True)
self.assertIn("frame_link: ssh failed", text)
for leaked in ("jane", "Jane", "JANE"):
self.assertNotIn(leaked, err.getvalue())
self.assertNotIn(leaked, text)
def quiet_log(test):
"""Catch frame_link's log lines, starting with nothing remembered."""
fl._logged.clear()
err = io.StringIO()
p = mock.patch.object(sys, "stderr", err)
p.start()
test.addCleanup(p.stop)
return err
class FailureLog(unittest.TestCase):
def test_the_same_failure_isnt_logged_every_retry(self):
err = quiet_log(self)
for _ in range(3):
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertEqual(err.getvalue().count("frame_link:"), 1)
for v in fl._logged.values():
v["at"] -= fl.LOG_REPEAT_EVERY + 1
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertIn("(and 2 more times)", err.getvalue())
self.assertNotIn("frame.local", err.getvalue())
def test_interleaved_failures_are_throttled_too(self):
err = quiet_log(self)
for _ in range(4):
fl.log_failure("find", "The Frame isn't answering.")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.")
self.assertEqual(err.getvalue().count("frame_link:"), 2)
for i in range(fl.LOG_REMEMBER + 10): # many different failures: memory stays bounded
fl.log_failure("ssh", f"failure number {i}")
self.assertLessEqual(len(fl._logged), fl.LOG_REMEMBER)
def test_hosts_ssh_names_go_known_or_not_and_whatever_the_case(self):
err = quiet_log(self)
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname bastion-jane: Name or service not known")
fl.log_failure("ssh", "ssh stopped", "channel 0: open failed: connect to host jane-office.example.com port 22")
fl.log_failure("ssh", "ssh stopped", "kex_exchange_identification: Connection reset by steamdeck-jane",
hosts=["STEAMDECK-JANE"])
fl.log_failure("ssh", "Timed out talking to frame-jane", "")
out = err.getvalue()
self.assertIn("hostname <host>", out)
self.assertIn("connect to host <host> port 22", out)
for leaked in ("bastion-jane", "jane-office", "steamdeck-jane", "frame-jane"):
self.assertNotIn(leaked, out)
def test_headsets_named_like_sshs_words_dont_shield_the_real_host(self):
err = quiet_log(self)
for saved in ("host", "hostname", "to"):
fl.log_failure("ssh", "ssh stopped", "ssh: connect to host bastion-jane port 22: Connection refused",
hosts=[saved])
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname jane-office.example.com: not known",
hosts=[saved])
out = err.getvalue()
self.assertIn("connect to host <host> port 22", out)
self.assertIn("hostname <host>", out)
for leaked in ("bastion-jane", "jane-office"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.hide_hosts("<host> and frame", ["host", "frame"]), "<host> and <host>")
def test_whole_names_go_before_sshs_words_and_only_real_tokens_are_spared(self):
for name, said in (("Jane Doe's work headset", "Timed out talking to Jane Doe's work headset"),
("Jane to Doe headset", "Jane to Doe headset stopped answering"),
("<Jane Doe>", "<Jane Doe> stopped answering")):
out = fl.scrub_failure(said, [name])
self.assertTrue(out.startswith("<host>") or out == "Timed out talking to <host>", out)
for leaked in ("Jane", "Doe"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.scrub_failure("Timed out talking to Jane Doe's work headset", ["Jane Doe's work headset"]),
"Timed out talking to <host>")
self.assertEqual(fl.hide_hosts("<user>@<host>: <ip>", ["user", "host", "ip"]), "<user>@<host>: <ip>")
def test_windows_user_names_with_spaces_go_whole(self):
err = quiet_log(self)
with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()):
fl.log_failure("ssh", r"Bad owner or permissions on C:\Users\Jane Doe/.ssh/config", "")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.",
"Jane Doe@frame: Permission denied (publickey).")
fl.log_failure("login", "refused", r"debug | ssh said: CORP\jane@frame's password: denied")
out = err.getvalue()
self.assertIn(r"C:\Users\<user>/.ssh/config", out)
self.assertIn("<user>@<host>: Permission denied", out)
for leaked in ("Jane", "Doe", "jane", "CORP"):
self.assertNotIn(leaked, out)
def test_whole_ssh_user_at_host_fields_and_home_folders_go(self):
import frame_telemetry as tm
with mock.patch.object(tm, "_user_names", return_value=set()):
self.assertEqual(tm.scrub(r"CORP\Jane Doe@jane-office.example.com: Permission denied (publickey)."),
"<user>@<host>: Permission denied (publickey).")
self.assertEqual(tm.scrub("jane@jane-office.example.com's password: "), "<user>@<host>'s password: ")
self.assertEqual(tm.scrub(r"Bad owner on C:\Users\O'Brien/.ssh/config"), r"Bad owner on C:\Users\<user>/.ssh/config")
self.assertEqual(tm.scrub(r"c:\users\Zoë Smith.Jr\.ssh\config"), r"c:\users\<user>\.ssh\config")
self.assertEqual(tm.scrub("/users/O'Brien/x and /HOME/jane doe/y"), "/users/<user>/x and /HOME/<user>/y")
self.assertEqual(tm.scrub("write to me@example.com today"), "write to <email> today")
class ConnectionDiagnostics(unittest.TestCase):
def test_address_kinds_never_the_address(self):
self.assertEqual(fl.address_kind("frame.local", "fe80::1%eth0"), ".local->ipv6 link-local")
self.assertEqual(fl.address_kind("frame.local", "192.168.1.5"), ".local->ipv4")
self.assertEqual(fl.address_kind("frame.local"), ".local")
self.assertEqual(fl.address_kind("fe80::1%5"), "ipv6 link-local")
self.assertEqual(fl.address_kind("2001:db8::1"), "ipv6")
self.assertEqual(fl.address_kind("192.168.1.5", "192.168.1.5"), "ipv4")
self.assertEqual(fl.address_kind("100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("frame.tail1234.ts.net", "100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("steamdeck"), "hostname")
self.assertEqual(fl.probes_summary([{"host": "frame", "label": "from ~/.ssh/config", "state": "timeout"}]),
"alias hostname timeout")
def test_hidden_hosts_leave_the_rest(self):
self.assertEqual(fl.hide_hosts("frame_link: Could not resolve hostname frame", ["frame"]),
"frame_link: Could not resolve hostname <host>")
self.assertEqual(fl.hide_hosts("ssh frame to frame.local", ["frame.local", "frame"], keep=("frame",)),
"ssh frame to <host>")
self.assertEqual(fl.hide_hosts("reset by SteamDeck", ["steamdeck"]), "reset by <host>")
self.assertEqual(fl.hide_operands("The headset took too long to answer."), "The headset took too long to answer.")
def test_ssh_version_is_rebuilt_from_its_numbers(self):
import frame_report as fr
for said, want in (("OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2\n", "OpenSSH for Windows 9.5p1, LibreSSL 3.8.2"),
("OpenSSH_9.9p1, LibreSSL 3.3.6\n", "OpenSSH 9.9p1, LibreSSL 3.3.6"),
("OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 15 Mar 2022\n", "OpenSSH 8.9p1"),
("OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n", "OpenSSH 9.6p1, OpenSSL 3.0.13"),
("OpenSSH_9.9p1-Jane-Doe-Laptop, LibreSSL 3.3.6\n", "unknown"),
("OpenSSH_9.9p1, OpenSSL jane-laptop\n", "OpenSSH 9.9p1"),
(r"C:\Users\Jane\OpenSSH-portable\ssh.exe: not found", "unknown"),
("", "unknown")):
with mock.patch.object(fr.frame_host, "run_ssh", return_value=subprocess.CompletedProcess([], 0, "", said)):
self.assertEqual(fr.ssh_version("ssh"), want, said)
def test_only_the_default_alias_is_named(self):
import frame_report as fr
self.assertEqual(fr.alias_kind("frame"), 'default ("frame")')
self.assertEqual(fr.alias_kind("jane-office.example.com"), "custom")
def test_a_slow_path_never_holds_up_a_report(self):
import frame_report as fr
release = threading.Event()
def slow():
release.wait(5)
return []
with mock.patch.dict(fr._ssh, {"thread": None, "line": None}), mock.patch.object(fr, "_ssh_on_path", slow), \
mock.patch.object(fr, "link", None), mock.patch.object(fr.shutil, "which", return_value="/usr/bin/ssh"), \
mock.patch.object(fr.frame_host, "run_ssh",
return_value=subprocess.CompletedProcess([], 0, "", "OpenSSH_9.9p1, LibreSSL 3.3.6\n")):
t0 = time.monotonic()
self.assertIn("Connection: no connector", fr.diagnostics())
self.assertIn("SSH: still being checked", fr.ssh_line())
self.assertLess(time.monotonic() - t0, 2)
thread = fr._ssh["thread"]
release.set()
thread.join(5)
self.assertTrue(fr._ssh["line"].startswith("SSH: "))
self.assertNotEqual(fr.ssh_line(), "SSH: still being checked")
def test_ssh_kinds(self):
import frame_report as fr
for path, kind in ((r"C:\WINDOWS\System32\OpenSSH\ssh.exe", "Windows OpenSSH (System32)"),
(r"C:\Program Files\OpenSSH\ssh.exe", "OpenSSH in Program Files"),
(r"C:\Program Files\Git\usr\bin\ssh.exe", "Git for Windows"),
("/usr/bin/ssh", "system OpenSSH"), ("/opt/homebrew/bin/ssh", "Homebrew or /usr/local"),
("/home/jane/bin/ssh", "other"), (None, "not found")):
self.assertEqual(fr.ssh_path_kind(path), kind)
def test_no_connector_says_so(self):
import frame_report as fr
with mock.patch.object(fr, "link", None):
self.assertIn("Connection: no connector", fr.diagnostics())
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
-136
View File
@@ -1,136 +0,0 @@
"""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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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_leads_with_the_reason(self):
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
# the reason comes first, in a few words, and only the countdown gets cut.
self.run_page(r'''
link.s = changed;
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
// 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>Checking the headset&#39;s identity failed<\/span>/);
''')
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()
+1 -5
View File
@@ -7,7 +7,6 @@ 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
@@ -84,10 +83,7 @@ class Helpers(unittest.TestCase):
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
if sys.platform == "win32" and not Path(bash).exists():
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_show_checks_the_source_before_anything_else(self):
-1
View File
@@ -1,5 +1,4 @@
"""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
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1,5 +1,4 @@
"""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
-64
View File
@@ -1,64 +0,0 @@
"""Frozen regression trace from the original mac-in-headset Swift controller.
The 600-state reference was produced at 1b90c64, before the C extraction:
60 seconds with a slow/congested middle, then recovery. No generated behavior
from the new implementation is used to calculate the expected digest.
"""
import ctypes as C
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
from frame_pc_capture import Controller
class ControllerTrace(unittest.TestCase):
def test_original_mac_congestion_and_recovery(self):
cc = shutil.which('cc') or shutil.which('gcc')
if not cc:
self.skipTest('C compiler not installed; native build jobs cover this trace')
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
lib = C.CDLL(str(path))
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
for name, (ret, args) in sig.items():
fn = getattr(lib, 'fc_'+name)
fn.restype, fn.argtypes = ret, args
c = Controller(lib, 60, 8000000)
due, states, seq = [], [], 0
try:
for tick in range(6000):
now = 10000000+tick*10000
for t, s in due:
if t <= now:
c.call('ack', s, now)
due = [(t, s) for t, s in due if t > now]
if tick % 2 == 0:
c.call('capture', now)
if c.call('gate', now, 1):
seq += 1
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
if tick % 10 == 0:
c.update(now)
x = c.state()
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
finally:
c.close()
if sys.platform == 'win32':
import _ctypes
_ctypes.FreeLibrary(lib._handle)
-388
View File
@@ -1,388 +0,0 @@
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
successful real-host capture.
"""
import ctypes
import http.client
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import threading
import time
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import frame_pc_agent as agent
import frame_pc_capture as capture
import frame_pcview
import frame_macview
from frame_stream_stats import Stats
from test_macview import WS
class GrantsTests(unittest.TestCase):
def test_source_bound_retry_ack_and_stop(self):
g = agent.Grants('master')
ticket = g.ticket('window:1')
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
key = g.redeem('window:1', {'t': ticket})
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
g.ack(key)
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
self.assertEqual(key, g.redeem('window:1', {'r': key}))
self.assertIsNone(g.redeem('window:2', {'r': key}))
g.revoke('window:1')
self.assertIsNone(g.redeem('window:1', {'r': key}))
def test_expiry_and_stop_before_redeem(self):
g = agent.Grants('master')
with mock.patch.object(agent.time, 'monotonic', return_value=1):
ticket = g.ticket('test')
with mock.patch.object(agent.time, 'monotonic', return_value=62):
self.assertIsNone(g.redeem('test', {'t': ticket}))
ticket = g.ticket('test')
g.revoke()
self.assertIsNone(g.redeem('test', {'t': ticket}))
class AdapterTests(unittest.TestCase):
def test_windows_uses_wgc_and_media_foundation(self):
for kind in ('window', 'display'):
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
self.assertIn('capture-api=wgc', pipeline)
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
self.assertNotIn('drop=true', pipeline)
with self.assertRaises(ValueError):
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
def test_linux_uses_only_portal_fd_and_node(self):
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
self.assertIn('pipewiresrc fd=19 path=47', p)
self.assertIn('tune=zerolatency', p)
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
def test_portal_input_clamps_and_releases_held_state(self):
native = mock.Mock()
native.lib.fc_portal_input.return_value = 1
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
inp.handle(dict(t='k', e='down', key='Control'))
inp.release()
calls = native.lib.fc_portal_input.call_args_list
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
self.assertFalse(inp.buttons or inp.keys)
def test_windows_release_and_negative_monitor_origin(self):
host = mock.Mock()
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
inp.release()
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
def test_refused_windows_focus_never_clicks_the_covering_app(self):
host = mock.Mock()
host.rect.return_value = (0, 0, 800, 600)
host.user.GetForegroundWindow.return_value = 99
host.user.SetForegroundWindow.return_value = False
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
host.send.assert_not_called()
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
view.ensure_tunnel = mock.Mock()
view.remote_port = 47900
view.show('window:12')
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
self.assertFalse(view.prefer_usb)
with self.assertRaises(frame_macview.MacViewError):
view.show('separate:12')
def test_session_end_releases_native_input_only_once(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event = threading.Event()
session.ws, session.input = mock.Mock(), mock.Mock()
session.released = False
session.end()
session.end()
session.input.release.assert_called_once()
self.assertTrue(session.stop_event.is_set())
def test_idle_source_is_not_a_stall(self):
# PipeWire/WGC deliver frames only on damage: a static desktop sends
# its first frame and then nothing. That must not end the session.
session = object.__new__(agent.Session)
session.lock, session.pending = threading.RLock(), {}
session.stats = Stats(lambda: 0)
session.stats.captured = 1
self.assertIsNone(session.stalled(60000000, 0, 0))
# A frame that entered the encoder and never came out is a fault.
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
self.assertIn('encoder', session.stalled(12000000, 0, 0))
self.assertIsNone(session.stalled(10000000, 0, 0))
# No initial frame at all after the pipeline opened is a fault.
session.pending.clear()
self.assertIn('capture', session.stalled(12000000, 0, 1))
self.assertIsNone(session.stalled(9000000, 0, 1))
def test_controller_is_inert_after_close(self):
lib = mock.Mock()
lib.fc_new.return_value = 1234
lib.fc_value.return_value = 100
controller = capture.Controller(lib, 60, 5000000)
controller.close()
lib.fc_free.assert_called_once_with(1234)
lib.reset_mock()
state = controller.state()
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
self.assertEqual(controller.call('gate', 1, 1), 0)
self.assertEqual(controller.update(1), 0)
controller.close()
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
def test_library_path_prepend_adds_no_empty_entry(self):
env = {}
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
with mock.patch.dict(os.environ, clear=True):
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
for name in ('PATH', 'LD_LIBRARY_PATH'):
parts = env.get(name, 'x').split(os.pathsep)
self.assertNotIn('', parts, name)
def test_malformed_viewer_input_is_dropped_not_fatal(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event, session.key_event = threading.Event(), threading.Event()
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
session.source = dict(src='display:1', w=100, h=100, portal=1)
session.input_enabled, session.released = True, False
session.native = mock.Mock()
session.native.now.return_value = 0
session.controller, session.agent = mock.Mock(), mock.Mock()
session.stats = Stats(lambda: 0)
session.produce = lambda: None
lib = mock.Mock()
lib.fc_portal_input.return_value = 1
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
session.ws = mock.Mock()
session.ws.receive.side_effect = [
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
with self.assertRaises(ConnectionError):
session.start()
# Every malformed message was skipped; the good move after them still ran.
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
self.assertAlmostEqual(moves[-1][2], .5*99)
self.assertEqual(session.ws.receive.call_count, 7)
def test_stats_keep_capture_time_and_bound_records(self):
stats = Stats(lambda: 10000000)
stats.input(dict(t='m', i=1, tv=9999990))
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
self.assertEqual(f['echo'], 1)
stats.report(dict(t='rx', s=f['s'], r=10000010))
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
for _ in range(4100):
stats.add(cap=10000001)
self.assertEqual(len(stats.frames), 4096)
class NativeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not capture.LIBRARY.exists():
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
raise AssertionError('CI required native PC libraries but they were not built')
raise unittest.SkipTest('native PC libraries not built on this host')
cls.token = 'test-' + os.urandom(8).hex()
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env)
line = cls.proc.stdout.readline()
if 'listening' not in line:
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
cls.port = int(line.rsplit(':', 1)[1])
@classmethod
def tearDownClass(cls):
cls.proc.stdin.close()
try:
rc = cls.proc.wait(10)
except subprocess.TimeoutExpired:
cls.proc.kill()
raise AssertionError('Native agent did not stop after EOF')
error = cls.proc.stderr.read()
cls.proc.stdout.close()
cls.proc.stderr.close()
if rc:
raise AssertionError('Native agent exited %s: %s' % (rc, error))
def request(self, path, method='GET'):
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
try:
conn.request(method, path)
response = conn.getresponse()
return response.status, response.read()
finally:
conn.close()
def tearDown(self):
self.request('/close?k='+self.token, 'POST')
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
def test_bundled_pipewire_context_needs_no_system_modules(self):
# No portal request, remote connection or desktop capture: create and
# destroy a private client context to test the dynamically loaded SPA
# and protocol modules that ldd cannot discover.
code = """
import ctypes as C, os
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
for name, ret, args in [
('pw_init', None, [C.c_void_p, C.c_void_p]),
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
('pw_context_destroy', None, [C.c_void_p]),
('pw_main_loop_destroy', None, [C.c_void_p])]:
fn = getattr(lib, name)
fn.restype, fn.argtypes = ret, args
lib.pw_init(None, None)
loop = lib.pw_main_loop_new(None)
assert loop, 'bundled SPA loop support did not load'
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
assert context, 'bundled PipeWire client modules did not load'
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
# The exported factory is video.adapt, which needs a live follower node to
# instantiate. Check its configured library and exported factory without
# pretending a headless context is a consented video stream.
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
assert plugin_name, 'video.adapt has no bundled library mapping'
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
class Factory(C.Structure):
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
plugin.spa_handle_factory_enum.restype = C.c_int
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
names.append(factory.contents.name)
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
lib.pw_context_destroy(context)
lib.pw_main_loop_destroy(loop)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
def test_last_raw_picture_survives_a_closed_gate(self):
# A source sends ONE changed frame, then becomes idle. It must reach
# the encoder after congestion clears, without needing another update.
code = """
import ctypes as C, sys, time
from frame_pc_capture import Native, GATE, Encoded, pipeline
native = Native()
start = None
closing = False
phases = []
def gate(stage, pts, capture, arrived):
global start
phases.append(stage)
if closing: return -1
if start is None: start = time.monotonic()
return int(stage == 1 or time.monotonic()-start >= .15)
callback = GATE(gate)
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
error = C.create_string_buffer(1024)
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
assert handle, error.value
try:
frame = Encoded()
result = 0
deadline = time.monotonic()+5
while not result and time.monotonic() < deadline:
result = native.lib.fc_capture_pull(handle, C.byref(frame))
assert result == 1, native.lib.fc_capture_error(handle)
assert frame.key and frame.size > 17
assert phases.count(0) == 1 and 2 in phases, phases
finally:
closing = True
native.lib.fc_capture_close(handle)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['PYTHONPATH'] = str(ROOT / 'ui')
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_auth_and_real_h264_stats(self):
self.assertEqual(self.request('/status')[0], 403)
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
self.assertEqual(status, 200)
ticket = json.loads(body)['ticket']
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
try:
self.assertEqual(ws.status, 101)
key, got, echo = None, 0, False
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
deadline = time.monotonic()+10
while time.monotonic() < deadline and (got < 5 or not echo):
op, data = ws.recv()
if op == 1:
m = json.loads(data)
self.assertNotEqual(m.get('t'), 'error', m)
if m.get('t') == 'hello':
key = m['r']
ws.send_text(json.dumps(dict(t='ack')))
elif op == 2:
got += 1
seq, echoed = struct.unpack('!II', data[9:17])
now = struct.unpack('!Q', data[1:9])[0]
echo |= echoed == 7
self.assertIn(b'\x00\x00\x00\x01', data[17:])
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
self.assertGreaterEqual(got, 5)
self.assertTrue(echo)
time.sleep(.1)
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
self.assertIn('controller', stats)
used = WS(self.port, '/stream?src=test&t='+ticket)
self.assertEqual(used.status, 403)
used.close()
self.request('/close?src=test&k='+self.token, 'POST')
revoked = WS(self.port, '/stream?src=test&r='+key)
self.assertEqual(revoked.status, 403)
revoked.close()
finally:
ws.close()
if __name__ == '__main__':
unittest.main()
-110
View File
@@ -1,110 +0,0 @@
"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
through the REST API even when its tag_name still says untagged-..., refuses
missing installers or digests, writes update.json's page from the tag, and
publishes with one PATCH. Nothing here talks to GitHub.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "publish-release.sh"
FAKEGH = ROOT / "tests" / "fakegh"
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb"]
def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
if assets is None:
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
for i, n in enumerate(INSTALLERS)]
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
"assets": assets}
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
return p, log, upload
def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
rel = draft(assets=draft()["assets"] + [old])
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
self.assertEqual(upload["version"], "9.8.7")
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
patch = next(c for c in log if "PATCH" in c)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
self.assertIn(f, patch)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
def test_an_untagged_draft_may_carry_a_subtitle(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["version"], "9.8.7")
def test_prefers_the_release_whose_tag_name_matches(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
def test_refuses_missing_or_unhashed_installers(self):
assets = draft()["assets"][1:]
assets[0] = dict(assets[0], digest=None)
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
self.assertIsNone(upload)
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("2 releases", p.stderr)
p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("no release", p.stderr)
# A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0, rel)
self.assertIn("no release", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("PATCH" in c for c in log))
p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("push it first", p.stderr)
def test_dry_run_changes_nothing(self):
p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
if __name__ == "__main__":
unittest.main()
-1144
View File
File diff suppressed because it is too large. Load diff
-161
View File
@@ -1,161 +0,0 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
-106
View File
@@ -1,106 +0,0 @@
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
the workflow and run with bash (as Actions runs it) against a stand-in gh
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to
GitHub.
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
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
def step_script():
"""The step's `run: |` block, dedented."""
lines = WORKFLOW.read_text().splitlines()
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
while lines[i].strip() != "run: |":
i += 1
indent = len(lines[i]) - len(lines[i].lstrip())
body = []
for line in lines[i + 1:]:
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
body.append(line)
return textwrap.dedent("\n".join(body)).strip() + "\n"
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
class DraftStep(unittest.TestCase):
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "step.sh").write_text(step_script())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
return p, log
def posts(self, log):
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
def patches(self, log):
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
def test_new_draft_uses_the_id_the_post_returns(self):
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
p, log = self.run_step([])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
[post] = self.posts(log)
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
self.assertIn(f, post)
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
self.assertIn("tag_name=v9.8.7", patch)
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
def test_a_rerun_reuses_the_draft_with_the_tag(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(self.posts(log), [])
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
def test_other_releases_alone_mean_a_new_draft(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
release(8, "v9.8.70", "Frame Control 9.8.70")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(len(self.posts(log)), 1)
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
def test_stops_when_the_tag_is_not_on_github(self):
p, log = self.run_step([], tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log), [])
if __name__ == "__main__":
unittest.main()
-122
View File
@@ -1,122 +0,0 @@
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()
+1 -194
View File
@@ -10,7 +10,6 @@ import http.client
import io
import json
import os
import shutil
import socket
import struct
import subprocess
@@ -79,15 +78,6 @@ 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"):
@@ -121,8 +111,6 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
("/api/open", {"what": "shot"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
@@ -133,10 +121,6 @@ class ServerGuards(unittest.TestCase):
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -295,80 +279,6 @@ class OneServer(unittest.TestCase):
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
and later, now and then, crash it (SIGSEGV) while background threads were still
loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
for attempt in range(5):
with self.subTest(attempt=attempt):
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
def test_staging_folders_cleared_when_stopped_mid_transfer(self):
"""The server leaves without interpreter teardown, so TemporaryDirectory cleanup
doesn't run for work still in progress: its own staging folders go on the way out,
and a dead server's at the next start."""
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
home = tempfile.mkdtemp(prefix="frame-stop-home-")
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
"FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
"LOCALAPPDATA": home, "APPDATA": home}
index_dir = Path(subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
"print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
env=env, capture_output=True, text=True, check=True).stdout.strip())
index_dir.mkdir(parents=True)
tmp = Path(tempfile.gettempdir())
def staged(folder, prefix, pid):
d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
(d / "app.apk").write_bytes(b"\0" * 4096)
return d
# Stopped as the app stops it: by closing stdin (the only way on Windows,
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
with self.subTest(stop=stop):
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
self.assertEqual([d for d in left_by_dead if d.exists()], [])
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
staged(index_dir, ".download-", proc.pid)]
if stop == "stdin":
proc.stdin.close()
else:
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
self.assertEqual([d for d in in_flight if d.exists()], [])
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
@@ -420,64 +330,6 @@ class ArtworkSettings(unittest.TestCase):
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageScripts(unittest.TestCase):
"""A page's scripts share one global scope, where a later function declaration
silently replaces an earlier one of the same name. Issue #68: the Devices page's
renderDetail replaced the Android store's, so every app opened as an empty
dialog (a bare horizontal line). Compiled together as one module, any top-level
name declared twice is a SyntaxError; node's parser handles nested scopes,
comments and strings."""
PAGES = ("index.html", "assistant.html", "mac-view.html")
@staticmethod
def page_scripts(html):
"""A page's inline scripts, then its /*.js scripts, in one source."""
import re
parts = re.findall(r"<script>([\s\S]*?)</script>", html)
parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'<script src="/([\w.-]+\.js)">', html)]
return "\n;\n".join(parts)
@staticmethod
def module_check(source):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "page.mjs"
path.write_text(source, encoding="utf-8")
r = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
return r.returncode, r.stderr
def test_the_check_catches_top_level_duplicates_only(self):
dup = "<script>function a() {}\nfunction a() {}</script>"
across = "<script>function a() {}</script><p>x</p><script> async function a() {}</script>"
same_line = "<script>function a() {} function a() {}</script>"
const_and_function = "<script>const a = 1, b = 2;\nfunction b() {}</script>"
for html in (dup, across, same_line, const_and_function):
rc, err = self.module_check(self.page_scripts(html))
self.assertNotEqual(rc, 0, html)
self.assertIn("has already been declared", err, html)
nested = ("<script>function a() {}\nfunction f() { function a() {} if (1) { const a = 2; } }\n"
"// function a() {}\nconst s = `function a() {}`;</script>")
self.assertEqual(self.module_check(self.page_scripts(nested)), (0, ""))
def test_no_top_level_name_is_declared_twice(self):
for name in self.PAGES:
source = self.page_scripts((ROOT / "ui" / name).read_text(encoding="utf-8"))
self.assertIn("function", source, name)
rc, err = self.module_check(source)
self.assertEqual(rc, 0, f"{name}: {err}")
def test_store_app_page_renders(self):
"""tests/page/store_detail.mjs runs the real store functions from index.html."""
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):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
@@ -549,11 +401,7 @@ class UnreachableMessages(unittest.TestCase):
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
"Host key verification failed.": "identity has changed",
"@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed.":
"Check the new key on the Devices tab",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict checking.\n"
"Host key verification failed.": "isn't saved yet",
"Host key verification failed.": "identity changed",
"kex_exchange_identification: read: Connection reset by peer": "dropped",
"Timed out talking to frame": "too long",
}
@@ -569,47 +417,6 @@ class UnreachableMessages(unittest.TestCase):
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
class ClipboardDesktopClosed(unittest.TestCase):
"""The headset's desktop being closed is the person's to fix: a 409, not a 5xx fault for telemetry."""
@classmethod
def setUpClass(cls):
sys.path.insert(0, str(ROOT / "ui"))
import server
cls.server = server
def paste_failing_with(self, message):
s = self.server
saved = s.ssh
def fake_ssh(remote, **kw):
raise s.Failure(message)
s.ssh = fake_ssh
try:
s.clipboard({"text": "hello"})
finally:
s.ssh = saved
def test_desktop_closed_is_a_409(self):
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("The headset's desktop isn't open (plasmashell is not running). "
"Open the desktop in the headset, then try again.")
self.assertEqual(cm.exception.status, 409)
self.assertIn("Open the desktop", str(cm.exception))
# Older wording (scripts/paste-to-frame.sh) is recognised too.
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("plasmashell is not running: open the desktop in the headset first.")
self.assertEqual(cm.exception.status, 409)
def test_other_failures_stay_faults(self):
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("Klipper call failed (bus: none)")
self.assertEqual(cm.exception.status, 502)
def test_paste_script_says_it(self):
self.assertIn(self.server.DESKTOP_CLOSED, self.server.PASTE)
class StatusProbe(unittest.TestCase):
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
-1
View File
@@ -1,4 +1,3 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
+4 -535
View File
@@ -6,7 +6,6 @@ Run: python3 -m unittest discover -s tests
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import sys
import tempfile
import threading
@@ -21,15 +20,9 @@ sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
import frame_host # noqa: E402
import frame_telemetry as tm # noqa: E402
def link_error(message, kind=RuntimeError):
"""An error as an ssh helper raises it when ssh couldn't reach the headset."""
return frame_host.link_failure(kind(message))
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
@@ -99,47 +92,6 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
def test_connection_failures_are_sent_once_per_session(self):
# The status poll meets an asleep or absent headset every few seconds (638 timeouts from
# six people in two weeks): one event per kind per session, whatever the address or route,
# for errors marked where ssh ran as ssh failing to reach the headset.
tm.update_settings({"diagnostics": True})
with mock.patch.object(tm.time, "time", return_value=1000.0):
for ip in ("192.168.1.20", "192.168.1.21", "10.0.0.5"):
for where in ("POST /api/comfort status", "job steam"):
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Connection timed out"))
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Host is down"))
tm.diagnostic(where, link_error("Timed out talking to frame"))
tm.diagnostic("POST /api/comfort status",
link_error("ssh: Could not resolve hostname frame: No such host is known."))
with mock.patch.object(tm.time, "time", return_value=1000.0 + 10 * tm.REPEAT_WINDOW):
tm.diagnostic("POST /api/comfort status", link_error("client_loop: send disconnect: Connection reset"))
tm.diagnostic("POST /api/comfort status", link_error("ssh: Could not resolve hostname frame"))
sent = [e["properties"] for e in self.queued()]
self.assertEqual([p["error_category"] for p in sent], ["frame_unreachable", "frame_not_set_up"])
self.assertTrue(all(p["$exception_message"].startswith("ssh: ") for p in sent))
def test_only_marked_errors_are_held_for_the_session(self):
# The same words without the mark (from a download, a file name, anything not ssh) keep
# the usual 10-minute window.
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("download failed: [Errno 54] Connection reset by peer"))
tm.diagnostic("job web", RuntimeError("urlopen error [Errno 60] Operation timed out"))
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "frame_unreachable", "download_failed", "frame_unreachable"])
def test_real_errors_are_still_sent_beside_connection_failures(self):
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("POST /api/comfort status", KeyError("battery"))
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", "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"])
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
@@ -159,79 +111,11 @@ 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):
@@ -266,39 +150,6 @@ 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})
@@ -329,107 +180,6 @@ class Scrub(unittest.TestCase):
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
def test_connection_failures_seen_from_released_versions(self):
# Wording from 0.4.0's error reports (addresses replaced), on Windows, macOS and Linux.
unreachable = [
"ssh: connect to host 192.168.1.20 port 22: Connection timed out",
"ssh: connect to host 192.168.1.20 port 22: Operation timed out",
"ssh: connect to host 192.168.1.20 port 22: No route to host",
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"ssh: connect to host 192.168.1.20 port 22: Unknown error",
"mux_client_request_session: read from master failed: Broken pipe\n"
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"client_loop: send disconnect: Connection reset",
"banner exchange: Connection to UNKNOWN port -1: Connection refused",
"Timed out talking to frame",
]
for message in unreachable:
self.assertEqual(tm.categorize(message)[0], "frame_unreachable", message)
for message in ("ssh: Could not resolve hostname frame: No such host is known.",
"ssh: Could not resolve hostname fe80::1%wireless_32773: No such host is known."):
self.assertEqual(tm.categorize(message)[0], "frame_not_set_up", message)
self.assertEqual(tm.categorize("ssh exited 1")[0], "other")
self.assertEqual(tm.categorize("ssh exited 255")[0], "other") # may be the command's own exit code
self.assertEqual(tm.categorize("download failed: [Errno 54] Connection reset by peer")[0], "download_failed")
self.assertEqual(tm.categorize("frame@10.0.0.2: Permission denied (publickey).")[0], "frame_auth")
def test_install_failure_categories(self):
import frame_android
self.assertEqual(tm.categorize(frame_android.LayerMissing(frame_android.LAYER_MISSING))[0], "layer_missing")
# The message 0.4.0 sent, so old and new builds land in the same bucket.
self.assertEqual(tm.categorize("the OpenXR compatibility layer isn't built; run "
"frame/openxr-compat/build.sh")[0], "layer_missing")
self.assertEqual(tm.categorize("could not prepare the APK for the Frame: ZIP64 APKs are unsupported")[0],
"apk_repack_failed")
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):
@@ -455,278 +205,6 @@ class Compat(Base):
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
class LinkFailureProvenance(Base):
"""Only ssh, where it runs, decides that it couldn't reach the headset; the error report
then holds that back for the session. Nothing in a message can claim it."""
def setUp(self):
super().setUp()
import frame_android
import frame_webinstall
import server
self.server, self.android, self.web = server, frame_android, frame_webinstall
for target, name, kw in ((server, "ensure_master", {}), (server, "LINK", {"new": None}),
(server, "repair_ssh_config", {"return_value": False})):
p = mock.patch.object(target, name, **kw)
p.start()
self.addCleanup(p.stop)
tm.update_settings({"diagnostics": True})
def ssh_fails(self, module, returncode, stderr, stdout=""):
"""Run module.ssh with ssh exiting `returncode`; -> the exception, as the route handler gets it."""
done = subprocess.CompletedProcess(["ssh"], returncode, stdout, stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=done):
try:
module.ssh("true")
except Exception as e: # noqa: BLE001
return e
self.fail("ssh did not raise")
def poll_fails(self):
e = self.ssh_fails(self.server, 255, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
tm.diagnostic("POST /api/comfort status", e)
def test_a_status_poll_that_cannot_reach_the_frame_is_sent_once(self):
for _ in range(5):
self.poll_fails()
for stderr in ("Warning: Permanently added '10.0.0.5' (ED25519) to the list of known hosts.\r\n"
"kex_exchange_identification: read: Connection reset by peer\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"\x1b[0mssh: connect to host 10.0.0.5 port 22: Unknown error\n"):
tm.diagnostic("POST /api/comfort status", self.ssh_fails(self.server, 255, stderr))
with mock.patch.object(frame_host, "run_ssh", side_effect=subprocess.TimeoutExpired("ssh", 30)):
with self.assertRaises(self.server.Failure) as caught:
self.server.ssh("true")
tm.diagnostic("POST /api/comfort status", caught.exception)
e = self.ssh_fails(self.android, 255, "ssh: connect to host 10.0.0.5 port 22: Host is down\n")
tm.diagnostic("job steam", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_unreachable"])
def test_host_key_and_login_refusals_are_sent_once(self):
# 0.4.2: each of these came in as a $exception from the status poll, again every 10 minutes.
for _ in range(3):
for stderr in ("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n"
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\n"
"IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\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",
"jane doe@frame-control-f7b4e9ac: Permission denied (publickey,password).\r\n",
"steamos@192.168.1.9: Permission denied (publickey,password).\n"):
e = self.ssh_fails(self.server, 255, stderr)
self.assertTrue(getattr(e, "frame_link_failed", False), stderr)
tm.diagnostic("POST /api/comfort status", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_auth"])
# A command on the Frame that was refused something isn't ssh refusing the headset.
for code, stderr in ((1, "steamos@frame: Permission denied (publickey).\n"),
(255, "cat: /root/x: Permission denied\n"),
(255, "scp: /home/steamos/x: Permission denied\n")):
self.assertFalse(getattr(self.ssh_fails(self.server, code, stderr), "frame_link_failed", False), stderr)
def test_a_command_that_fails_on_the_frame_is_not_marked(self):
self.poll_fails()
for module in (self.server, self.android):
for code, stderr in ((255, ""), (255, "x: ssh: connect to host frame port 22: Connection timed out\n"),
(1, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\n")):
e = self.ssh_fails(module, code, stderr)
self.assertFalse(getattr(e, "frame_link_failed", False), (module.__name__, code, stderr))
# Review round 4: the command's own output (stdout) isn't ssh speaking.
e = self.ssh_fails(module, 255, "", stdout="ssh: connect to host frame port 22: Connection timed out\n")
self.assertFalse(getattr(e, "frame_link_failed", False), module.__name__)
def request(self, path, body):
"""POST to the real server, as the page does."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port)
c.request("POST", path, json.dumps(body), {"X-Frame-UI": self.server.UI_KEY, "Content-Type": "application/json"})
r = c.getresponse()
result = r.status, json.loads(r.read())
c.close()
return result
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), \
mock.patch.object(self.android, "_meta_or_fail", return_value=meta), \
mock.patch.object(self.server.frame_titles, "ensure_utils"):
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 503) # the page shows its offline message
for t in (1000.0, 1000.0 + 2 * tm.REPEAT_WINDOW):
with mock.patch.object(tm.time, "time", return_value=t):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 503)
self.assertEqual(self.request("/api/titles", {"action": "launch", "id": "mygame"})[0], 503)
# Not a link failure: still reported, through the same re-wrap.
with mock.patch.object(self.android, "_meta_or_fail", side_effect=self.android.FrameError("boom")):
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"]
# 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,
# were held back with the poll's connection failures.
self.poll_fails()
names = ("Connection closed by frame port 22.zip", "Connection reset by frame port 22.apk",
"kex_exchange_identification: read.zip", "banner exchange: payload.zip",
"ssh: connect to host frame port 22: x.zip", "Timed out talking to frame")
for name in names:
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
"cancel": False}
plan = {"name": None, "exe": None, "url": "https://example.com/x.zip", "kind": "zip"}
error = self.web.WebInstallError(f"{name} doesn't match the manifest's sha256; not installing it")
with mock.patch.object(self.web, "download", side_effect=error):
self.server._webinstall_run(plan, job)
self.assertEqual(job["phase"], "error")
exceptions = [e["properties"] for e in self.queued() if e["event"] == "$exception"]
self.assertEqual([p["error_category"] for p in exceptions], ["frame_unreachable"] + ["download_failed"] * len(names))
finished = [e["properties"] for e in self.queued() if e["event"] == "install_finished"]
self.assertEqual({p["error_category"] for p in finished}, {"download_failed"})
class InstallFinished(unittest.TestCase):
def test_failure_category_only_no_text(self):
"""install_finished carries a fixed category for a failure, never the message or a file name."""
import frame_android
with mock.patch.object(tm, "capture") as capture, mock.patch.object(tm, "diagnostic"):
tm.install_finished("apk", False, 0.0, frame_android.LayerMissing(
"C:\\Users\\Bob\\My Game.apk: " + frame_android.LAYER_MISSING), catalog=False)
props = capture.call_args[0][1]
self.assertEqual(props["error_category"], "layer_missing")
self.assertNotIn("Bob", repr(props))
self.assertEqual(set(props), {"kind", "ok", "seconds", "error_category", "catalog"})
tm.install_finished("apk", False)
self.assertEqual(capture.call_args[0][1]["error_category"], "other")
class ApkInstallJobs(unittest.TestCase):
"""The whole job path: what the page is told, and what telemetry sends, once."""
def setUp(self):
import frame_android
import frame_webinstall
import server
self.server, self.android, self.web = server, frame_android, frame_webinstall
for target, name, kw in ((server, "ensure_master", {}), (server.frame_catalog, "app", {}),
(server.frame_catalog, "add_report", {})):
p = mock.patch.object(target, name, **kw)
p.start()
self.addCleanup(p.stop)
def run_job(self, body):
job = self.server.android(body)["job"]
for _ in range(500):
with self.server._jobs_lock:
state = dict(self.server._jobs[job])
if state["done"]:
return state
time.sleep(0.01)
self.fail("job did not finish")
def test_missing_layer_warning_reaches_every_completion_message(self):
meta = {"label": "VR", "package": "org.test.vr", "vr_issues": [self.android.LAYER_MISSING_NOTE]}
with mock.patch.object(self.server.frame_catalog, "install", return_value=meta):
state = self.run_job({"action": "install", "package": "org.test.vr"})
self.assertIsNone(state["error"])
self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
with mock.patch.object(self.server.frame_apk_versions, "install", return_value=meta):
state = self.run_job({"action": "install", "package": "org.test.vr", "url": "https://example.com/v.apk"})
self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
with mock.patch.object(self.android, "install", return_value=meta):
self.assertIn(self.android.LAYER_MISSING_NOTE, self.web.dispatch("/tmp/v.apk")["message"])
# A normal install says nothing about the layer.
with mock.patch.object(self.server.frame_catalog, "install", return_value=dict(meta, vr_issues=[])):
state = self.run_job({"action": "install", "package": "org.test.vr"})
self.assertNotIn("OpenXR", state["message"])
def test_unexpected_failure_is_one_event_and_one_diagnostic(self):
info = {"package": "org.test.flat", "label": "Flat", "abis": [], "min_sdk": None, "vr": False,
"vr_activity": False, "launchable": True, "repairable": False}
sent = []
tm._seen_errors.clear()
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install",
side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
mock.patch.object(self.server.frame_catalog, "install",
side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
state = self.run_job({"action": "install", "package": "org.test.flat"})
self.assertIn("FileNotFoundError", state["error"])
events = [e for e, _ in sent]
self.assertEqual(events.count("install_finished"), 1)
self.assertEqual(events.count("$exception"), 1, events)
finished = next(p for e, p in sent if e == "install_finished")
self.assertEqual((finished["ok"], finished["error_category"]), (False, "tool_missing"))
# The same failure through a web link: one event and one diagnostic there too.
sent.clear()
tm._seen_errors.clear()
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
"cancel": False}
plan = {"name": None, "exe": None, "url": "https://example.com/x.apk", "kind": "apk"}
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install",
side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
mock.patch.object(self.server.frame_webinstall, "download",
side_effect=lambda plan, tmp, **kw: os.path.join(tmp, "x.apk")):
self.server._webinstall_run(plan, job)
self.assertEqual(job["phase"], "error")
self.assertIn("FileNotFoundError", job["error"])
events = [e for e, _ in sent]
self.assertEqual(events.count("install_finished"), 1, events)
self.assertEqual(events.count("$exception"), 1, events)
finished = next(p for e, p in sent if e == "install_finished")
self.assertEqual((finished["kind"], finished["error_category"]), ("apk", "tool_missing"))
# A FrameError still gets its install diagnostic (the job reports it as well, as before).
sent.clear()
tm._seen_errors.clear()
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install", side_effect=self.android.FrameError("timed out talking to frame")), \
mock.patch.object(self.server.frame_catalog, "install",
side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
self.run_job({"action": "install", "package": "org.test.flat"})
self.assertEqual([e for e, _ in sent].count("install_finished"), 1)
class ApkInstallReports(unittest.TestCase):
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
@@ -746,12 +224,6 @@ class ApkInstallReports(unittest.TestCase):
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
self.assertIs(self.install_finished.call_args[0][1], False)
def test_install_without_layer_is_flagged(self):
self.server.apk_installed({"package": "com.private.vr", "xr_layer_missing": True}, {}, None, 4.0)
self.assertIs(self.install_finished.call_args[1]["xr_layer_missing"], True)
self.server.apk_installed({"package": "com.private.vr"}, {}, None, 4.0)
self.assertIsNone(self.install_finished.call_args[1]["xr_layer_missing"])
def test_connection_trouble_is_not_reported(self):
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
@@ -919,16 +391,14 @@ class ReportProblem(Base):
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com", "contactFollowup": True})
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com"})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -951,9 +421,8 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", "", None, None, None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
["short"], good]
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
+5 -244
View File
@@ -2,15 +2,9 @@
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
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
@@ -57,9 +51,12 @@ class Mapping(unittest.TestCase):
self.assertEqual(t.locate(5, None)["display"], ":0")
def test_focus_display_is_decoded(self):
t = self.t
saved = t.xprop_root
self.addCleanup(lambda: setattr(t, "xprop_root", saved))
for values, want in (([12602, 0, 58], ":1"), ([12346], ":0"), ([], None), ([0x41], None)):
self.assertEqual(self.t.focus_display({"GAMESCOPE_FOCUS_DISPLAY": values}), want)
self.assertIsNone(self.t.focus_display({}))
t.xprop_root = lambda d, n, v=values: v
self.assertEqual(t.focus_display(), want)
def test_ascii_table_covers_printable_characters(self):
for code in range(0x20, 0x7F):
@@ -262,236 +259,6 @@ class ServerChecks(unittest.TestCase):
self.s.remote_touch({"events": [{"dx": 1}] * (self.s.INPUT_BATCH_LIMIT + 1)})
class FindingPanels(unittest.TestCase):
"""panels() with faked headset probes: where gamescope's properties are, and why nothing was found (#73)."""
@classmethod
def setUpClass(cls):
import frame_touch
cls.t = frame_touch
def setUp(self):
t = self.t
names = ("read_props", "window_info", "window_pid", "displays", "desktop_running")
saved = {n: getattr(t, n) for n in names}
saved_which = t.shutil.which
self.real_displays = saved["displays"]
self.real_read_props = saved["read_props"]
def restore():
for n, v in saved.items():
setattr(t, n, v)
t.shutil.which = saved_which
t._ROOT.clear()
self.addCleanup(restore)
t._ROOT.clear()
self.props = {} # (display, property) -> values
self.windows = {} # (display, window) -> info
self.reads = [] # displays whose gamescope properties were read
t.read_props = lambda d, limit=5.0: self.reads.append(d) or {n: list(v) for (dd, n), v in self.props.items() if dd == d}
t.window_info = lambda d, w: self.windows.get((d, w))
t.window_pid = lambda d, w: None
t.displays = lambda: [":0", ":1", ":2"]
t.desktop_running = lambda: False
t.shutil.which = lambda tool: "/usr/bin/" + tool
def plasma_on(self, display):
# SteamOS 0.4.1: the nested Plasma desktop (kwin_wayland, app id 0) is a window on :0.
self.props[(display, "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222]
self.props[(display, "GAMESCOPE_FOCUSED_WINDOW")] = [4194307]
self.props[(display, "GAMESCOPE_FOCUS_DISPLAY")] = [0x303A] # ":0"
self.windows[(":0", 4194307)] = {"name": "KDE Wayland Compositor", "x": 0, "y": 0, "width": 1280, "height": 800}
self.windows[(":0", "root")] = {"width": 1280, "height": 720}
def test_desktop_panel_on_display_0(self):
self.plasma_on(":0")
out = self.t.panels()
self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]], [(4194307, ":0", True)])
self.assertNotIn("why", out)
def test_display_0_costs_one_read(self):
# The layout of every build seen so far: one read of :0's properties for the whole run.
self.plasma_on(":0")
self.t.panels()
self.assertEqual(self.reads, [":0"])
def test_gamescope_properties_on_another_display_are_found(self):
# A build that numbers its displays differently: :0 has no gamescope properties.
self.plasma_on(":1")
self.assertEqual(self.t.gamescope()[0], ":1")
self.reads.clear()
self.t._ROOT.clear()
self.assertEqual(len(self.t.panels()["panels"]), 1)
self.assertEqual(self.reads, [":0", ":1"]) # read once, then reused by focus() and focusable()
def test_other_displays_are_probed_briefly(self):
limits = []
self.t.read_props = lambda d, limit=5.0: limits.append((d, limit)) or {}
self.assertEqual(self.t.gamescope(), (":0", {}))
self.assertEqual(limits, [(":0", 5.0), (":1", self.t.ROOT_PROBE_TIMEOUT), (":2", self.t.ROOT_PROBE_TIMEOUT)])
def test_input_helper_remembers_the_display(self):
# The input helper lives for a session: a found display is kept, a failed search retried later.
t = self.t
self.plasma_on(":1")
t.gamescope(); t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":1"])
self.props.clear()
self.reads.clear()
t._ROOT.clear()
t.gamescope(); t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":2", ":0"]) # given up: :0 for ROOT_RETRY
t._ROOT[1] -= t.ROOT_RETRY
self.reads.clear()
t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":2"])
def test_read_props_parses_xprop(self):
t = self.t
saved = t.subprocess.run
self.addCleanup(setattr, t.subprocess, "run", saved)
out = ("GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 0, 2, 3, 769, 4\n"
"GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 0\nGAMESCOPE_FOCUS_DISPLAY: not found.\n")
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, out, "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": [1, 0, 2, 3, 769, 4],
"GAMESCOPE_FOCUSED_WINDOW": [0]})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSED_WINDOW: not found.\n", "")
self.assertEqual(self.real_read_props(":0"), {})
def hang(*a, **k):
raise subprocess.TimeoutExpired(a[0], k.get("timeout"))
t.subprocess.run = hang
self.assertEqual(self.real_read_props(":0"), {})
def test_deadline_returns_what_was_found(self):
t, clock = self.t, [0.0]
self.plasma_on(":0")
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222, 5, 0, 1, 6, 0, 1]
saved = t.time.monotonic
self.addCleanup(setattr, t.time, "monotonic", saved)
t.time.monotonic = lambda: clock[0]
info = self.windows.get
def slow(d, w): # every xwininfo takes 1 s: the run is out of time after a few
t.budget()
clock[0] += 1
return info((d, w))
t.window_info = slow
out = t.panels(limit=7) # focus: 2 reads; the first panel: 3 displays and its root
self.assertEqual([p["window"] for p in out["panels"]], [4194307])
self.assertIn("took too long", out["why"])
self.assertIn("Some may be missing", out["why"])
self.assertIsNone(t.DEADLINE[0]) # the next run (or the input helper) has no deadline left over
def test_listed_window_that_isnt_x11_explains_itself(self):
self.plasma_on(":0")
del self.windows[(":0", 4194307)] # say, a desktop drawn as a Wayland window
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("can't find its window", out["why"])
self.assertIn("Headset view", out["why"])
def test_desktop_running_but_not_listed(self):
self.t.desktop_running = lambda: True
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("desktop is running", out["why"])
def test_nothing_open(self):
out = self.t.panels()
self.assertEqual(out, {"focus": None, "focus_display": None, "panels": [],
"why": "No app panel is open in the headset. Open the desktop or an app in "
"the headset, then try again."})
def test_missing_x_tools_are_named(self):
self.plasma_on(":0")
self.t.shutil.which = lambda tool: None if tool == "xwininfo" else "/usr/bin/" + tool
self.windows.clear() # xwininfo can't run, so nothing resolves
self.assertIn("no xwininfo", self.t.panels()["why"])
def test_displays_include_abstract_sockets(self):
# /tmp/.X11-unix not visible to the SSH session: the abstract sockets still name the displays.
import builtins
t = self.t
real_open, real_listdir = builtins.open, t.os.listdir
def fake_open(path, *a, **k):
if path == "/proc/net/unix":
return io.StringIO("0000: 00000002 0 ... @/tmp/.X11-unix/X1\n0000: ... @/tmp/.X11-unix/X0\n"
"0000: ... /run/user/1000/wayland-0\n")
return real_open(path, *a, **k)
def fake_listdir(path):
if path == "/tmp/.X11-unix":
raise FileNotFoundError(path)
return real_listdir(path)
builtins.open, t.os.listdir = fake_open, fake_listdir
try:
self.assertEqual(self.real_displays(), [":0", ":1"])
finally:
builtins.open, t.os.listdir = real_open, real_listdir
FAKE_XPROP = """#!/bin/sh
sleep "${FAKE_X_DELAY:-0}"
if [ "$1" = "-root" ] && [ "$DISPLAY" = ":0" ]; then
echo "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 11, 0, 101, 12, 769, 102"
echo "GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 11"
echo "GAMESCOPE_FOCUS_DISPLAY(CARDINAL) = 12346"
fi
"""
FAKE_XWININFO = """#!/bin/sh
sleep "${FAKE_X_DELAY:-0}"
[ "$DISPLAY" = ":0" ] || exit 1
echo 'xwininfo: Window id: 0xb "Desktop"'
echo ' Absolute upper-left X: 0'
echo ' Absolute upper-left Y: 0'
echo ' Width: 1280'
echo ' Height: 800'
echo ' Map State: IsViewable'
"""
@unittest.skipIf(sys.platform == "win32", "fake X tools are shell scripts; frame_touch.py runs on the Frame")
class PanelsAsAFreshProcess(unittest.TestCase):
"""`frame_touch.py panels` the way the server runs it: a new process each request, with
fake xprop and xwininfo first on PATH (two displays, :0 and :1)."""
def run_panels(self, delay):
with tempfile.TemporaryDirectory() as bin_dir:
for name, body in (("xprop", FAKE_XPROP), ("xwininfo", FAKE_XWININFO)):
path = Path(bin_dir) / name
path.write_text(body)
path.chmod(0o755)
env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""), FAKE_X_DELAY=str(delay),
PYTHONDONTWRITEBYTECODE="1")
code = ("import sys; sys.path.insert(0, sys.argv[1]); import frame_touch as t; "
"t.displays = lambda: [':0', ':1']; t.desktop_running = lambda: False; "
"sys.argv = ['frame_touch.py', 'panels']; sys.exit(t.main())")
start = time.monotonic()
r = subprocess.run([sys.executable, "-c", code, str(ROOT / "ui")], env=env, capture_output=True,
text=True, timeout=60)
took = time.monotonic() - start
self.assertEqual(r.returncode, 0, r.stderr)
return json.loads(r.stdout), took
def test_lists_panels(self):
out, _ = self.run_panels(0)
self.assertEqual(out["focus"], 11)
self.assertEqual(out["focus_display"], ":0")
self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]],
[(11, ":0", True), (12, ":0", False)])
self.assertNotIn("why", out)
def test_slow_x_tools_stay_inside_the_servers_timeout(self):
# Review of #83: X tools answering in 4.6 s each would take well over the server's
# 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
out, took = self.run_panels(4.6)
self.assertLess(took, 15, out)
self.assertEqual(out["panels"], [])
self.assertIn("took too long", out["why"])
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageGestures(unittest.TestCase):
"""Control's gestures and queue (tests/page/ctrl_gestures.mjs runs the real functions from index.html)."""
@@ -501,12 +268,6 @@ class PageGestures(unittest.TestCase):
timeout=60)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_desktop_panel_list(self):
# #73: the headset's reason for no panels, and a failed listing, reach Capture and Control.
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "desk_panels.mjs")], capture_output=True, text=True,
timeout=60)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
class Script(unittest.TestCase):
def test_helper_compiles_on_the_frames_python(self):
-1
View File
@@ -1,5 +1,4 @@
"""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
+11 -22
View File
@@ -386,28 +386,17 @@ class ServerJobs(unittest.TestCase):
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
s = self.server
with tempfile.TemporaryDirectory() as cache, \
mock.patch.object(s.frame_host, "cache_dir", lambda *parts: Path(cache).joinpath(*parts)):
# Downloads, title staging, patched VR APKs, files staged for the
# assistant, and app-index downloads (in the cache folder).
places = s._tmp_places()
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
s.apk_fdroid.TMP_PREFIX})
for folder, prefix in places:
folder.mkdir(parents=True, exist_ok=True)
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-", dir=folder)
try:
s.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
s.sweep_tmp(own=True) # on the way out: this server's own go too
self.assertFalse(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
# Downloads and title staging (unzipped titles) are both swept.
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
try:
self.server.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
-162
View File
@@ -1,162 +0,0 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+1 -2
View File
@@ -27,7 +27,6 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
_refreshing = {} # source id -> background refresh thread
@@ -531,7 +530,7 @@ def _load(source, force=False):
return found[:2]
cache.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more