mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-20 10:01:25 +02:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
baaddd7622 | ||
|
|
07fc7ca3ff | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
2554309c7d | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 | ||
|
|
b89538f506 | ||
|
|
311fb5f6c7 | ||
|
|
93ae2b1bd1 | ||
|
|
12bd10ee42 | ||
|
|
218df4f312 | ||
|
|
ce1a271064 | ||
|
|
dbf6ffc79c | ||
|
|
a8d5c4b44d | ||
|
|
b365a85665 | ||
|
|
8aeeaef666 | ||
|
|
4e374de040 | ||
|
|
d17e911c5b | ||
|
|
6746a8220d | ||
|
|
5fb02770db | ||
|
|
62d233f9c9 | ||
|
|
2861567d15 | ||
|
|
fcf2d12411 | ||
|
|
b5c8d2f19d | ||
|
|
4a489e4606 | ||
|
|
0304575e5b | ||
|
|
81bf646b0e | ||
|
|
4f99cd75d0 | ||
|
|
3e814cfa1a | ||
|
|
6e566db1a7 | ||
|
|
f221a5f588 | ||
|
|
b1f33c804c | ||
|
|
db9bee2903 | ||
|
|
22df550ff2 | ||
|
|
7871aa1ca0 | ||
|
|
0a9bb2e0c3 | ||
|
|
b25855d13a | ||
|
|
9e70cdb48a | ||
|
|
9da1dea974 | ||
|
|
241e35d3b6 | ||
|
|
70309d3cec | ||
|
|
76878403c3 | ||
|
|
105b92a0dd | ||
|
|
d356d9a9d5 | ||
|
|
6f5aa33197 | ||
|
|
e47eefec7c | ||
|
|
d92a9fc7ae | ||
|
|
875e92ea7b | ||
|
|
f9a87e76e8 | ||
|
|
95ccae5494 | ||
|
|
d58a926257 | ||
|
|
9292ce0a4c | ||
|
|
f5f3a1f9ca | ||
|
|
9d062b7ba0 | ||
|
|
a46c28d6c8 | ||
|
|
a41f635a7c | ||
|
|
67bb71a708 | ||
|
|
0b9a165d83 | ||
|
|
88a2fe6728 | ||
|
|
ba9706b69e |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
|
||||
@@ -14,7 +14,40 @@ 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:
|
||||
@@ -40,15 +73,27 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# 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: |
|
||||
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
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
|
||||
@@ -6,11 +6,25 @@ 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 '')
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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 : ""}`);
|
||||
}
|
||||
+7
-2
@@ -11,6 +11,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -27,6 +28,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
@@ -115,10 +117,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -278,6 +281,8 @@ ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.6",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -29,6 +29,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"afterPack": "build/check-resources.js",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
@@ -38,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -49,6 +51,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
@@ -81,7 +84,7 @@
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"XrApiLayer_FRAME_compat.json",
|
||||
"prebuilt/**/*.so"
|
||||
"prebuilt/**/*.so.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
function read() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||
} catch { return {}; }
|
||||
}
|
||||
function write(key, value) {
|
||||
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||
const saved = { ...read(), [key]: value };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
module.exports = { preferenceStore };
|
||||
@@ -10,6 +10,10 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
preferences: {
|
||||
read: () => ipcRenderer.invoke("preferences:read"),
|
||||
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { preferenceStore } = require("../preferences");
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, "client-preferences.json");
|
||||
}
|
||||
|
||||
test("desktop packaging includes every local application module dependency", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const packaged = new Set(require("../package.json").build.files);
|
||||
for (const file of ["main.js", "preload.js"]) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||
const target = match[1].slice(2) + ".js";
|
||||
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("client choices survive a new native session and local server origin", t => {
|
||||
const file = fixture(t);
|
||||
const first = preferenceStore(file);
|
||||
assert.deepEqual(first.read(), {});
|
||||
first.write("mvSeparate", "0");
|
||||
first.write("padUsed", "1");
|
||||
// A new process/session reads the same client store, regardless of its URL.
|
||||
const second = preferenceStore(file);
|
||||
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||
second.write("mvSeparate", "1");
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the two non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
}
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("a missing or damaged preference file retains usable defaults", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.deepEqual(store.read(), {});
|
||||
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||
fs.writeFileSync(file, bad);
|
||||
assert.deepEqual(store.read(), {});
|
||||
}
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
@@ -51,6 +51,27 @@ 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: [] };
|
||||
|
||||
+18
-5
@@ -106,12 +106,24 @@ 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 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,
|
||||
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),
|
||||
// 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 })) };
|
||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||
return { version, notes: String(r.body || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
@@ -177,6 +177,63 @@ See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## Store and Library in Frame Control
|
||||
|
||||
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
|
||||
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
|
||||
*Social Network* is Web & chat).
|
||||
|
||||
Apps built around a phone are hidden from browsing unless **Show phone-only
|
||||
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
|
||||
a category that only makes sense in a phone's own system (launchers, keyboards,
|
||||
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
|
||||
fitness, alarms, system tools) unless a useful category says otherwise
|
||||
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
|
||||
part (a widget, a launcher, a battery tool), and apps that need hardware the
|
||||
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
|
||||
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
|
||||
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
|
||||
|
||||
Without a search, each source offers its best 300 for the chosen category:
|
||||
apps with a banner first, then with screenshots, then the newest.
|
||||
|
||||
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
|
||||
|
||||
The **APK Store** panel is the Store above, drawn for the laser: the same
|
||||
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
|
||||
its own floating panel:
|
||||
|
||||
```sh
|
||||
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
scripts/store-on-frame.sh close
|
||||
```
|
||||
|
||||
`open` copies the same bundle the iPhone app uses
|
||||
(`ios/scripts/make_frame_bundle.py`) to
|
||||
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
|
||||
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
|
||||
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
|
||||
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
|
||||
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
|
||||
all run on the Frame, so the Mac can go away after `open`.
|
||||
|
||||
The page has a category rail (Home, the store's categories, Installed), eight
|
||||
art cards a page, and a featured VR app on Home. It turns pages instead of
|
||||
scrolling, because a tagged panel gets mouse clicks and a drag would select
|
||||
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
|
||||
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
|
||||
the real sources): Home, categories, Installed, the detail sheet. **Still to
|
||||
check in the headset:** that the panel opens and takes laser clicks, that an
|
||||
install completes when run on the Frame, and that **Open** brings up the app's
|
||||
panel.
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
|
||||
+56
-6
@@ -104,7 +104,9 @@ 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.
|
||||
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.
|
||||
|
||||
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
|
||||
@@ -121,9 +123,57 @@ 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. After reinstalling SteamOS the
|
||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||
connection save the new one.
|
||||
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)).
|
||||
|
||||
## One server at a time
|
||||
|
||||
@@ -144,7 +194,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`, `forget-identity`, `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`, `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` |
|
||||
|
||||
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
|
||||
@@ -161,7 +211,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_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
|
||||
"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",
|
||||
|
||||
+59
-3
@@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
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
|
||||
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
|
||||
@@ -96,6 +97,61 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal the **Library** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
+83
-10
@@ -33,23 +33,46 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
every computer, which lets us count headsets rather than installs; it can't
|
||||
be turned back into the machine-id or a serial number. `headsets_saved` is how
|
||||
many headsets this copy of Frame Control has set up.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
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:
|
||||
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:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
@@ -93,7 +116,11 @@ 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.
|
||||
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.
|
||||
|
||||
## Report a problem
|
||||
|
||||
@@ -122,10 +149,56 @@ 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.
|
||||
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.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
report. Environment details are kept first, then the newest lines. **Show
|
||||
|
||||
+11
-4
@@ -24,13 +24,20 @@ 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 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
|
||||
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
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
|
||||
| Script | Runs on | Purpose |
|
||||
|---|---|---|
|
||||
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
|
||||
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
|
||||
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
|
||||
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
|
||||
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
|
||||
|
||||
@@ -261,6 +261,16 @@ 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).
|
||||
|
||||
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
|
||||
@@ -249,6 +249,10 @@ 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):
|
||||
|
||||
|
||||
@@ -190,8 +190,11 @@ 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`.
|
||||
Its SHA-256 is recorded below with the APK checks.
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
||||
@@ -8,5 +8,8 @@ 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"
|
||||
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
||||
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
||||
# 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"
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,8 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
|
||||
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
|
||||
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
|
||||
@@ -35,6 +37,8 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
|
||||
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
|
||||
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
|
||||
@@ -100,6 +104,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8A11F3431826A26B247C0695 /* AppModel.swift */,
|
||||
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
|
||||
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
|
||||
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
|
||||
);
|
||||
@@ -111,6 +116,7 @@
|
||||
children = (
|
||||
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
|
||||
);
|
||||
path = FrameControlTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -260,6 +266,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
|
||||
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
|
||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
|
||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
|
||||
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
|
||||
@@ -279,6 +286,7 @@
|
||||
files = (
|
||||
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
|
||||
/// Install links that arrived before the page was ready for them.
|
||||
@Published var pendingInstallLinks: [InstallLink] = []
|
||||
|
||||
/// Native recovery remains reachable even when WebKit cannot draw a page.
|
||||
@Published private(set) var webRecoveryMessage: String?
|
||||
@Published private(set) var webReloadID = 0
|
||||
|
||||
func webContentFailed(at url: URL, message: String) {
|
||||
guard phase == .ready(url) else { return }
|
||||
webRecoveryMessage = message
|
||||
}
|
||||
|
||||
func retryWebContent() {
|
||||
webRecoveryMessage = nil
|
||||
webReloadID += 1
|
||||
}
|
||||
|
||||
private var link: FrameLink?
|
||||
private var server: HeadsetServer?
|
||||
private var forwarder: PortForwarder?
|
||||
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
/// "host", "host:port" or "[v6]:port", plus a user name.
|
||||
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
|
||||
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
|
||||
let rest = target.host[target.host.index(after: close)...]
|
||||
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
|
||||
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
FrameSettings.parse(host: host, user: user)
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
|
||||
private func invalidate() {
|
||||
attempt += 1
|
||||
retrying = false
|
||||
webRecoveryMessage = nil
|
||||
}
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// The persisted SSH target and its editable, lossless address form.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
|
||||
var address: String {
|
||||
let name = host.contains(":") ? "[\(host)]" : host
|
||||
return port == 22 ? name : "\(name):\(port)"
|
||||
}
|
||||
|
||||
static func parse(host: String, user: String) -> FrameSettings? {
|
||||
var host = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = user.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var port = 22
|
||||
if host.hasPrefix("[") {
|
||||
guard let close = host.firstIndex(of: "]") else { return nil }
|
||||
let rest = host[host.index(after: close)...]
|
||||
if !rest.isEmpty {
|
||||
guard rest.hasPrefix(":"), let value = Int(rest.dropFirst()) else { return nil }
|
||||
port = value
|
||||
}
|
||||
host = String(host[host.index(after: host.startIndex)..<close])
|
||||
} else if host.filter({ $0 == ":" }).count == 1, let colon = host.lastIndex(of: ":") {
|
||||
guard let value = Int(host[host.index(after: colon)...]) else { return nil }
|
||||
port = value
|
||||
host = String(host[..<colon])
|
||||
}
|
||||
guard !host.isEmpty, !user.isEmpty, (1...65535).contains(port) else { return nil }
|
||||
return FrameSettings(host: host, port: port, user: user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each search owns its callbacks. Cancelling invalidates even probes that cannot
|
||||
/// be cancelled while Network.framework finishes opening a connection.
|
||||
struct FrameSearchState {
|
||||
private(set) var generation = UUID()
|
||||
private(set) var manualAddress: String?
|
||||
|
||||
mutating func start(manualAddress: String?) -> UUID {
|
||||
generation = UUID()
|
||||
let typed = manualAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.manualAddress = typed.isEmpty ? nil : typed
|
||||
return generation
|
||||
}
|
||||
|
||||
mutating func stop() { generation = UUID() }
|
||||
func accepts(_ generation: UUID) -> Bool { self.generation == generation }
|
||||
var discovers: Bool { manualAddress == nil }
|
||||
|
||||
static func address(manual: String, discovered: String?) -> String {
|
||||
let typed = manual.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return typed.isEmpty ? (discovered ?? "frame.local") : typed
|
||||
}
|
||||
}
|
||||
|
||||
/// One automatic recovery per session; only a deliberate retry or a new session
|
||||
/// replenishes it. A successful load alone must not create a process-crash loop.
|
||||
struct WebDocumentState {
|
||||
struct Delivery: Equatable {
|
||||
let generation: UUID
|
||||
let documentID: String
|
||||
}
|
||||
|
||||
private(set) var generation = UUID()
|
||||
private(set) var documentID: String?
|
||||
private(set) var delivering = false
|
||||
private var reloads = 0
|
||||
|
||||
mutating func beginDocument() {
|
||||
generation = UUID()
|
||||
documentID = nil
|
||||
delivering = false
|
||||
}
|
||||
|
||||
mutating func resetSession() {
|
||||
reloads = 0
|
||||
beginDocument()
|
||||
}
|
||||
|
||||
mutating func shouldReloadAfterFailure() -> Bool {
|
||||
beginDocument()
|
||||
guard reloads < 1 else { return false }
|
||||
reloads += 1
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func ready(_ id: String, generation: UUID) {
|
||||
guard self.generation == generation else { return }
|
||||
documentID = id
|
||||
}
|
||||
|
||||
mutating func beginDelivery() -> Delivery? {
|
||||
guard !delivering, let documentID else { return nil }
|
||||
delivering = true
|
||||
return Delivery(generation: generation, documentID: documentID)
|
||||
}
|
||||
|
||||
/// Only acknowledgement from the same live document may consume a link.
|
||||
mutating func finishDelivery(_ delivery: Delivery, acknowledged: Bool) -> Bool {
|
||||
guard delivery.generation == generation, delivering, delivery.documentID == documentID else { return false }
|
||||
delivering = false
|
||||
if !acknowledged { documentID = nil }
|
||||
return acknowledged
|
||||
}
|
||||
}
|
||||
|
||||
/// Small app-scoped preferences survive the tunnel's intentionally ephemeral port.
|
||||
struct FramePreferences {
|
||||
private let defaults: UserDefaults
|
||||
private static let keys = ["mvSeparate", "padUsed"]
|
||||
private static let prefix = "frame.control.preference."
|
||||
|
||||
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
|
||||
|
||||
func read() -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for key in Self.keys {
|
||||
if let value = defaults.object(forKey: Self.prefix + key) as? String, ["0", "1"].contains(value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func write(key: String, value: String) -> Bool {
|
||||
guard Self.keys.contains(key), ["0", "1"].contains(value) else { return false }
|
||||
defaults.set(value, forKey: Self.prefix + key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
private var browser: NWBrowser?
|
||||
private var probeTask: Task<Void, Never>?
|
||||
private var fallback = "frame.local"
|
||||
private var search = FrameSearchState()
|
||||
private let probe: @Sendable (String) async -> Bool
|
||||
private let discoveryEnabled: Bool
|
||||
|
||||
func start(fallback: String?) {
|
||||
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
|
||||
discoveryEnabled: Bool = true) {
|
||||
self.probe = probe
|
||||
self.discoveryEnabled = discoveryEnabled
|
||||
}
|
||||
|
||||
func start(fallback: String?, manual: Bool = false) {
|
||||
stop()
|
||||
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
|
||||
let mine = search.start(manualAddress: manual ? fallback : nil)
|
||||
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
|
||||
found = nil
|
||||
since = Date()
|
||||
browse()
|
||||
if search.discovers && discoveryEnabled { browse(generation: mine) }
|
||||
probeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let host = self.fallback
|
||||
if self.found == nil, await Self.sshAnswers(host: host) {
|
||||
self.found = Found(host: host, name: host)
|
||||
guard let self, self.search.accepts(mine) else { return }
|
||||
if self.found == nil {
|
||||
let answers = await self.probe(host)
|
||||
guard !Task.isCancelled, self.search.accepts(mine) else { return }
|
||||
if answers, self.found == nil { self.found = Found(host: host, name: host) }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
search.stop()
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
probeTask?.cancel()
|
||||
probeTask = nil
|
||||
}
|
||||
|
||||
private func browse() {
|
||||
private func browse(generation: UUID) {
|
||||
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
for result in results {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else { continue }
|
||||
Self.resolve(result.endpoint) { host in
|
||||
Task { @MainActor in
|
||||
guard let self, let host else { return }
|
||||
guard let self, self.search.accepts(generation), self.search.discovers, let host else { return }
|
||||
// A found device is used over the fallback probe.
|
||||
self.found = Found(host: host, name: name)
|
||||
}
|
||||
@@ -91,12 +103,8 @@ final class FrameFinder: ObservableObject {
|
||||
|
||||
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
|
||||
nonisolated static func sshAnswers(host address: String) async -> Bool {
|
||||
var host = address, port: UInt16 = 22
|
||||
if let target = AppModel.parse(host: address, user: "steamos") {
|
||||
host = target.host
|
||||
port = UInt16(target.port)
|
||||
}
|
||||
return await sshAnswers(host: host, port: port)
|
||||
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
|
||||
return await sshAnswers(host: target.host, port: UInt16(target.port))
|
||||
}
|
||||
|
||||
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
|
||||
|
||||
@@ -4,13 +4,6 @@ import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
|
||||
@@ -140,12 +133,6 @@ final class FrameLink: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
|
||||
@@ -10,15 +10,31 @@ struct RootView: View {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
ConnectingView(step: step, host: model.settings?.address) { model.showSetup() }
|
||||
case .failed(let message) where model.retrying && !model.needsPairing:
|
||||
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
|
||||
WaitingView(host: model.settings?.address ?? "", detail: message, deviceName: model.deviceName,
|
||||
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
.accessibilityHidden(model.webRecoveryMessage != nil)
|
||||
.allowsHitTesting(model.webRecoveryMessage == nil)
|
||||
if let message = model.webRecoveryMessage {
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "arrow.clockwise.circle").font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text("Reload Frame Control").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
Button("Try again") { model.retryWebContent() }
|
||||
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||
Button("Change headset") { model.showSetup() }
|
||||
}
|
||||
.padding(32).frame(maxWidth: 480)
|
||||
}
|
||||
.background(Color.frameBackground.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
@@ -33,20 +49,37 @@ extension Color {
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
/// Recovery controls remain reachable on short screens and with large text.
|
||||
private struct RecoveryLayout<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
content
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: geometry.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +92,23 @@ struct FailedView: View {
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
|
||||
.font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if needsPairing {
|
||||
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
} else if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
}
|
||||
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,34 +121,37 @@ struct WaitingView: View {
|
||||
let reachable: () -> Void
|
||||
let change: () -> Void
|
||||
@State private var pulse = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(pulse ? 1 : 0.55)
|
||||
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
RecoveryLayout {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.opacity(reduceMotion || pulse ? 1 : 0.55)
|
||||
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
|
||||
Text("Waiting for your Frame").font(.title3.bold())
|
||||
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
|
||||
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
|
||||
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
|
||||
Button("Connect to a different Frame", action: change).font(.footnote)
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
|
||||
reachable()
|
||||
return
|
||||
.padding(28)
|
||||
.frame(maxWidth: 480)
|
||||
.onAppear { pulse = true }
|
||||
.task(id: host) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
|
||||
reachable()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ struct SetupView: View {
|
||||
@State private var showHelp = false
|
||||
@FocusState private var passwordFocused: Bool
|
||||
|
||||
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
|
||||
/// An explicit target always wins over discovery.
|
||||
private var host: String {
|
||||
let typed = manualHost.trimmingCharacters(in: .whitespaces)
|
||||
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
|
||||
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -34,19 +33,19 @@ struct SetupView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(Color.frameBackground)
|
||||
.onAppear {
|
||||
manualHost = model.settings?.host ?? ""
|
||||
manualHost = model.settings?.address ?? ""
|
||||
user = model.settings?.user ?? "steamos"
|
||||
var fallback = model.settings?.host
|
||||
var fallback = model.settings?.address
|
||||
#if DEBUG
|
||||
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
|
||||
#endif
|
||||
finder.start(fallback: fallback)
|
||||
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
|
||||
}
|
||||
.onDisappear { finder.stop() }
|
||||
// After a few seconds of not finding it, say exactly what to check.
|
||||
.task(id: finder.since) {
|
||||
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
||||
showHelp = true
|
||||
if !Task.isCancelled { showHelp = true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ struct SetupView: View {
|
||||
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
|
||||
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.onSubmit { finder.start(fallback: manualHost) }
|
||||
.onSubmit { finder.start(fallback: manualHost, manual: true) }
|
||||
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("User", text: $user)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
@@ -149,7 +148,9 @@ struct SetupView: View {
|
||||
}
|
||||
.onChange(of: manualHost) { _, value in
|
||||
// A typed address replaces the search.
|
||||
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
|
||||
showHelp = false
|
||||
let typed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
finder.start(fallback: typed, manual: !typed.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,14 +31,13 @@ struct WebShell: UIViewRepresentable {
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
|
||||
context.coordinator.load(url, reloadID: model.webReloadID)
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
@@ -47,16 +46,21 @@ struct WebShell: UIViewRepresentable {
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.__frameDocumentID = crypto.randomUUID();
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
preferences: {
|
||||
read: () => call("preferencesRead"),
|
||||
write: (key, value) => call("preferencesWrite", { key, value }),
|
||||
},
|
||||
notify: (message, request) => call("notify", { message, request }),
|
||||
readClipboard: () => call("readClipboard"),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -64,7 +68,18 @@ struct WebShell: UIViewRepresentable {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
private(set) var document = WebDocumentState()
|
||||
private(set) var reloadID = 0
|
||||
private var activeNavigation: WKNavigation?
|
||||
private let preferences = FramePreferences()
|
||||
|
||||
@MainActor
|
||||
func load(_ url: URL, reloadID: Int) {
|
||||
loaded = url
|
||||
self.reloadID = reloadID
|
||||
document.resetSession()
|
||||
activeNavigation = web?.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
@@ -118,6 +133,14 @@ struct WebShell: UIViewRepresentable {
|
||||
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
|
||||
}
|
||||
}
|
||||
case "preferencesRead":
|
||||
replyHandler(preferences.read(), nil)
|
||||
case "preferencesWrite":
|
||||
guard let args = arg as? [String: Any], let key = args["key"] as? String,
|
||||
let value = args["value"] as? String, preferences.write(key: key, value: value) else {
|
||||
return replyHandler(nil, "Invalid preference")
|
||||
}
|
||||
replyHandler(true, nil)
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "setUpConnection":
|
||||
@@ -135,9 +158,20 @@ struct WebShell: UIViewRepresentable {
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
guard let id = arg as? String, let web,
|
||||
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return replyHandler(nil, "Invalid document")
|
||||
}
|
||||
let generation = document.generation
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(json)") { [weak self] result, error in
|
||||
guard let self else { return replyHandler(nil, "Page closed") }
|
||||
if error == nil, result as? Bool == true {
|
||||
self.document.ready(id, generation: generation)
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
replyHandler(nil, nil)
|
||||
}
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
@@ -145,13 +179,23 @@ struct WebShell: UIViewRepresentable {
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req),
|
||||
let text = String(data: json, encoding: .utf8),
|
||||
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
|
||||
let id = String(data: idData, encoding: .utf8) else {
|
||||
_ = document.finishDelivery(delivery, acknowledged: false)
|
||||
return
|
||||
}
|
||||
// Keep the head queued until the live document confirms its callback ran.
|
||||
web.evaluateJavaScript("window.__frameDocumentID === \(id) && window.__frameInstallLink(\(text)) === true") { [weak self] result, error in
|
||||
guard let self, let loaded = self.loaded, self.model.phase == .ready(loaded),
|
||||
self.reloadID == self.model.webReloadID else { return }
|
||||
guard self.document.finishDelivery(delivery, acknowledged: error == nil && result as? Bool == true) else { return }
|
||||
if self.model.pendingInstallLinks.first == link { self.model.pendingInstallLinks.removeFirst() }
|
||||
self.deliverInstallLinks()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +230,55 @@ struct WebShell: UIViewRepresentable {
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// A new document cannot receive links using the previous page's callback.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
activeNavigation = navigation
|
||||
document.beginDocument()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
recover(webView, message: "The app page stopped responding. Try loading it again, or change headset.")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
navigationFailed(webView, navigation: navigation, error: error)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func navigationFailed(_ webView: WKWebView, navigation: WKNavigation?, error: Error) {
|
||||
guard navigation === activeNavigation, (error as NSError).code != NSURLErrorCancelled else { return }
|
||||
recover(webView, message: "Couldn't load the app page. \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func recover(_ webView: WKWebView, message: String) {
|
||||
guard let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
|
||||
model.webRecoveryMessage == nil else { return }
|
||||
activeNavigation = nil
|
||||
if document.shouldReloadAfterFailure() {
|
||||
// Reload the original authenticated URL, including its session key.
|
||||
activeNavigation = webView.load(URLRequest(url: loaded))
|
||||
} else {
|
||||
model.webContentFailed(at: loaded, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
|
||||
@MainActor
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
#if DEBUG
|
||||
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
#if !NATIVE_CONTRACT_STANDALONE
|
||||
@testable import Frame_Control
|
||||
#endif
|
||||
|
||||
final class NativeSessionTests: XCTestCase {
|
||||
func testSavedAddressesRoundTripWithoutLosingPortOrIPv6() throws {
|
||||
for host in ["frame.local", "100.100.100.2", "2001:db8::42", "fe80::1%en0"] {
|
||||
for port in [22, 2222, 65535] {
|
||||
let target = FrameSettings(host: host, port: port, user: "alex")
|
||||
let saved = try JSONDecoder().decode(FrameSettings.self, from: JSONEncoder().encode(target))
|
||||
XCTAssertEqual(FrameSettings.parse(host: saved.address, user: saved.user), target)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(FrameSettings.parse(host: " [2001:db8::42]:2222\n", user: " steamos "),
|
||||
FrameSettings(host: "2001:db8::42", port: 2222))
|
||||
for host in ["", "frame.local:0", "frame.local:65536", "frame.local:no", "[::1]:no", "[::1]junk", "[::1"] {
|
||||
XCTAssertNil(FrameSettings.parse(host: host, user: "steamos"), host)
|
||||
}
|
||||
}
|
||||
|
||||
func testManualTargetWinsAndSearchCallbacksExpire() {
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " 100.100.100.2:2222 ", discovered: "192.168.1.237"), "100.100.100.2:2222")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: " \n", discovered: "192.168.1.237"), "192.168.1.237")
|
||||
XCTAssertEqual(FrameSearchState.address(manual: "", discovered: nil), "frame.local")
|
||||
var search = FrameSearchState()
|
||||
let bonjour = search.start(manualAddress: nil)
|
||||
XCTAssertTrue(search.discovers)
|
||||
let manual = search.start(manualAddress: "100.100.100.2:2222")
|
||||
XCTAssertFalse(search.discovers)
|
||||
XCTAssertFalse(search.accepts(bonjour))
|
||||
XCTAssertTrue(search.accepts(manual))
|
||||
search.stop()
|
||||
XCTAssertFalse(search.accepts(manual))
|
||||
_ = search.start(manualAddress: "")
|
||||
XCTAssertTrue(search.discovers)
|
||||
}
|
||||
|
||||
func testRecoveryIsBoundedUntilAnExplicitRetry() {
|
||||
var page = WebDocumentState()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.beginDocument()
|
||||
page.ready("reloaded", generation: page.generation)
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
XCTAssertFalse(page.shouldReloadAfterFailure())
|
||||
page.resetSession()
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
}
|
||||
|
||||
func testReplacementDocumentsCannotConsumeQueuedLinks() throws {
|
||||
var page = WebDocumentState()
|
||||
var queue = ["first", "second"]
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
let oldGeneration = page.generation
|
||||
page.ready("first document", generation: oldGeneration)
|
||||
let oldDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
XCTAssertNil(page.beginDelivery(), "Serialize delivery while an acknowledgement is pending")
|
||||
XCTAssertTrue(page.shouldReloadAfterFailure())
|
||||
page.ready("stale readiness", generation: oldGeneration)
|
||||
XCTAssertNil(page.beginDelivery())
|
||||
if page.finishDelivery(oldDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let failedDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(failedDelivery, acknowledged: false) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["first", "second"])
|
||||
XCTAssertNil(page.beginDelivery(), "An evaluation error needs fresh readiness")
|
||||
page.ready("replacement", generation: page.generation)
|
||||
let liveDelivery = try XCTUnwrap(page.beginDelivery())
|
||||
if page.finishDelivery(liveDelivery, acknowledged: true) { queue.removeFirst() }
|
||||
XCTAssertEqual(queue, ["second"])
|
||||
XCTAssertNotNil(page.beginDelivery())
|
||||
page.beginDocument()
|
||||
XCTAssertNil(page.beginDelivery(), "Even healthy main-document navigation resets readiness")
|
||||
}
|
||||
|
||||
func testPreferencesPersistAcrossSessionsAndRejectOtherKeysAndValues() throws {
|
||||
let suite = "frame-control-contract-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let first = FramePreferences(defaults: defaults)
|
||||
XCTAssertEqual(first.read(), [:])
|
||||
XCTAssertTrue(first.write(key: "mvSeparate", value: "0"))
|
||||
XCTAssertTrue(first.write(key: "padUsed", value: "1"))
|
||||
let second = FramePreferences(defaults: try XCTUnwrap(UserDefaults(suiteName: suite)))
|
||||
XCTAssertEqual(second.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
for (key, value) in [("other", "1"), ("mvSeparate", "false"), ("padUsed", ""), ("padUsed", "2")] {
|
||||
XCTAssertFalse(second.write(key: key, value: value))
|
||||
}
|
||||
XCTAssertEqual(first.read(), ["mvSeparate": "0", "padUsed": "1"])
|
||||
XCTAssertNil(defaults.object(forKey: "frame.control.preference.other"))
|
||||
defaults.set("bad", forKey: "frame.control.preference.mvSeparate")
|
||||
defaults.set("1", forKey: "frame.control.preference.unknown")
|
||||
XCTAssertEqual(second.read(), ["padUsed": "1"])
|
||||
defaults.set(true, forKey: "frame.control.preference.padUsed")
|
||||
XCTAssertEqual(second.read(), [:], "Only stored strings are supported")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", "frame/android/*.js",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json", "frame/openxr-compat/prebuilt/arm64-v8a/*.so.gz",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
+74
-16
@@ -3,23 +3,65 @@
|
||||
# (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 v0.4.0
|
||||
# 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.
|
||||
set -eu
|
||||
tag="${1:?usage: $0 vX.Y.Z}"
|
||||
dry=""
|
||||
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
|
||||
tag="${1:?usage: $0 [--dry-run] 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=$(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")
|
||||
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")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
@@ -30,16 +72,32 @@ done
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
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],
|
||||
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],
|
||||
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
||||
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
|
||||
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
|
||||
echo "ok update.json"
|
||||
|
||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
||||
echo "published $tag; running copies will offer it at their next check"
|
||||
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"
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
|
||||
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
|
||||
# panel on the Steam Frame, so you can browse and install apps from inside.
|
||||
# See docs/apks.md → "The store in the headset".
|
||||
#
|
||||
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
|
||||
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
|
||||
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
|
||||
# needed after `open`.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
# scripts/store-on-frame.sh close # close the panel and stop the server
|
||||
# scripts/store-on-frame.sh status
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
HERE=${0:A:h}
|
||||
ROOT=${HERE:h}
|
||||
PORT=47812 # the Frame's 127.0.0.1 only
|
||||
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
|
||||
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
|
||||
REMOTE=.local/share/frame-control/store-server
|
||||
|
||||
cmd=${1:-}
|
||||
|
||||
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
|
||||
# server on the Frame has exactly the files the desktop app ships.
|
||||
deploy() {
|
||||
local tmp version
|
||||
tmp=$(mktemp -d)
|
||||
trap "rm -rf ${(q)tmp}" EXIT
|
||||
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
|
||||
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
|
||||
print -u2 "==> Copying Frame Control ($version) to the Frame"
|
||||
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
|
||||
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
|
||||
fi
|
||||
# Older versions, unless a server is still running from one.
|
||||
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
|
||||
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
|
||||
print -r -- "$version"
|
||||
}
|
||||
|
||||
# Runs on the Frame. Args: dir port appid key
|
||||
OPEN=$(cat <<'EOF'
|
||||
set -u
|
||||
dir=$HOME/$1 port=$2 appid=$3 key=$4
|
||||
export DISPLAY=:0 LC_ALL=C.UTF-8
|
||||
unset WAYLAND_DISPLAY
|
||||
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
|
||||
if [ -x "$HOME/chromium-xr/chrome" ]; then
|
||||
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
|
||||
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
|
||||
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
|
||||
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
|
||||
else
|
||||
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
|
||||
exit 3
|
||||
fi
|
||||
# A fresh server each time, so it runs this version with this key.
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
|
||||
# Match the browser by its profile flag and the server by its script path: this
|
||||
# launcher's own command line holds the bundle path, so a looser pattern kills it.
|
||||
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
|
||||
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
|
||||
sleep 0.5
|
||||
cd "$dir" || exit 1
|
||||
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
|
||||
>/tmp/frame-store-server.log 2>&1 </dev/null &
|
||||
echo $! > /tmp/frame-store-server.pid
|
||||
up=
|
||||
for _ in $(seq 1 40); do
|
||||
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
|
||||
sleep 0.25
|
||||
done
|
||||
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
|
||||
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
|
||||
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
|
||||
--disable-features=Translate,MediaRouter --window-size=1280,800 \
|
||||
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
|
||||
# Tag the window so it's its own panel (docs/panels.md).
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 0.5
|
||||
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
|
||||
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
|
||||
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
|
||||
echo "panel valve.steam.desktopgame.$appid window $win"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "Chromium started, but its window didn't appear within 30 s:" >&2
|
||||
tail -n 15 /tmp/frame-store.log >&2
|
||||
exit 1
|
||||
EOF
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
open)
|
||||
version=$(deploy | tail -n 1)
|
||||
key=$(openssl rand -hex 16)
|
||||
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
|
||||
;;
|
||||
close)
|
||||
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
|
||||
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
|
||||
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
|
||||
;;
|
||||
status)
|
||||
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
|
||||
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
|
||||
;;
|
||||
*) sed -n '2,15p' "$0"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
|
||||
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
|
||||
throwaway container and keeps its real home, which sandbox would move."""
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,72 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
|
||||
|
||||
async function session(native, initial = {}, duringRead) {
|
||||
const saved = { ...initial }, element = { checked: true, disabled: false };
|
||||
const storage = { getItem: key => saved[key] ?? null, setItem: (key, value) => { saved[key] = String(value); } };
|
||||
for (const key of ['mvSeparate', 'padUsed']) Object.defineProperty(storage, key, {
|
||||
get: () => saved[key], set: value => { saved[key] = String(value); },
|
||||
});
|
||||
const calls = [];
|
||||
const context = vm.createContext({ window: { frameApp: native ? { preferences: native } : undefined }, localStorage: storage,
|
||||
$: () => element, log: (...args) => calls.push(args),
|
||||
api: async () => ({ state: 'off' }), padStart: () => calls.push('start'), padShow: s => calls.push(s.state) });
|
||||
const marker = html.indexOf('// ---- client preferences');
|
||||
const start = marker >= 0 ? marker : html.indexOf('$("mvSeparate").checked =');
|
||||
vm.runInContext(html.slice(start, html.indexOf('$("macview").onclick', start)), context);
|
||||
if (duringRead) duringRead(context, element);
|
||||
await vm.runInContext('typeof clientPreferences === "undefined" ? Promise.resolve() : clientPreferences.ready', context);
|
||||
return { element, saved, calls, context, async change(value) {
|
||||
element.checked = value; element.onchange({ target: element });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
} };
|
||||
}
|
||||
|
||||
test('a layout choice survives moving from one native local origin to another', async () => {
|
||||
const values = {};
|
||||
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
|
||||
const first = await session(native);
|
||||
await first.change(false);
|
||||
assert.equal(first.element.checked, false);
|
||||
const second = await session(native); // fresh origin storage
|
||||
assert.equal(second.element.checked, false);
|
||||
});
|
||||
|
||||
test('native values win over stale origin values and missing values migrate', async () => {
|
||||
const values = { mvSeparate: '0' };
|
||||
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
|
||||
const page = await session(native, { mvSeparate: '1', padUsed: '1' });
|
||||
assert.equal(page.element.checked, false);
|
||||
assert.equal(values.padUsed, '1');
|
||||
});
|
||||
|
||||
test('ordinary browser storage and a failed native read retain usable choices', async () => {
|
||||
assert.equal((await session(null, { mvSeparate: '0' })).element.checked, false);
|
||||
const native = { read: async () => { throw Error('Unavailable'); }, write: async () => true };
|
||||
const page = await session(native, { mvSeparate: '0' });
|
||||
assert.equal(page.element.checked, false);
|
||||
assert.equal(page.element.disabled, false);
|
||||
});
|
||||
|
||||
test('starting input during a native read wins over its stale remembered value', async () => {
|
||||
let resolveRead;
|
||||
const writes = [];
|
||||
const native = { read: () => new Promise(resolve => { resolveRead = resolve; }), write: async (key,value) => writes.push({key,value}) };
|
||||
const page = await session(native, {}, context => {
|
||||
vm.runInContext("clientPreferences.set('padUsed','1')",context);
|
||||
resolveRead({padUsed:'0'});
|
||||
});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
|
||||
assert.deepEqual(writes,[{key:'padUsed',value:'1'}]);
|
||||
});
|
||||
|
||||
test('one failed migration does not hide another native preference', async () => {
|
||||
const native = {read:async()=>({padUsed:'1'}),write:async()=>{throw Error('disk unavailable');}};
|
||||
const page=await session(native,{mvSeparate:'0'});
|
||||
assert.equal(page.element.checked,false);
|
||||
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
// 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 [];},close(){this.open=false;},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
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(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
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(){},
|
||||
padReset(){},ctrlReset(){},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 = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
|
||||
const androidPending = new Set();`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
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 Control Center workflow reaches its intentional workspace', () => {
|
||||
const {workspaceFixture}=require('./workspace_fixture.cjs');
|
||||
const f=workspaceFixture();
|
||||
const expected={view:'capture',shots:'capture',vrTools:'tools',comfort:'tools',pad:'tools',library:'games',getgames:'games',sideloaded:'games',android:'apps',display:'settings',androidLibrary:'apps',appStore:'apps',power:'settings',transfer:'tools',media:'tools',linuxApps:'apps',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
|
||||
}
|
||||
assert.equal(f.run('PAGES.length'),4);
|
||||
});
|
||||
+27
-4
@@ -10,6 +10,7 @@ import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,7 +23,22 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
||||
|
||||
ENABLED = os.environ.get('FRAME_E2E') == '1'
|
||||
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
|
||||
# the container runtime's marker file): these tests drive ui/server.py with the real
|
||||
# home, ~/.ssh and cache, which is only safe in a throwaway container.
|
||||
REQUESTED = os.environ.get('FRAME_E2E') == '1'
|
||||
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
|
||||
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
|
||||
ENABLED = REQUESTED and IN_CONTAINER
|
||||
if ENABLED:
|
||||
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
|
||||
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
|
||||
# database, not $HOME. (These tests used to get this through test_frame_apk.)
|
||||
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
|
||||
atexit.register(shutil.rmtree, _data, ignore_errors=True)
|
||||
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
|
||||
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
|
||||
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
|
||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
||||
|
||||
|
||||
def require():
|
||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
||||
if not ENABLED:
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
"""Skips (the module, at module level; the class, in setUpClass) unless this is
|
||||
scripts/e2e.sh's container with the fake Frame up."""
|
||||
if ENABLED:
|
||||
return
|
||||
if REQUESTED:
|
||||
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
|
||||
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
|
||||
|
||||
# ---- the fake Frame's control port --------------------------------------------
|
||||
@@ -97,6 +118,7 @@ class Server:
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
require() # never a server on this computer's real data
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
require()
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -6,6 +6,8 @@ import sys
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
import harness
|
||||
from harness import HOME, exists, ok, state, upload, wait_for
|
||||
from test_frame_apk import apk, manifest, resources
|
||||
from apk_fixtures import apk, manifest, resources
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
FAKEFRAME_CTL: http://fakeframe:9999
|
||||
FAKEFRAME_HOST: fakeframe
|
||||
FRAME_E2E: "1"
|
||||
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||
interval: 1s
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/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))
|
||||
+96
-3
@@ -3,10 +3,20 @@
|
||||
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."""
|
||||
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
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -17,12 +27,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"):
|
||||
if args[i] in ("-o", "-O", "-p", "-l", "-F", "-W"):
|
||||
key = args[i]
|
||||
val = args[i + 1]
|
||||
if key == "-o":
|
||||
k, _, v = val.partition("=")
|
||||
opts[k.lower()] = v
|
||||
opts.setdefault(k.lower(), v)
|
||||
else:
|
||||
opts[key] = val
|
||||
i += 2
|
||||
@@ -30,6 +40,12 @@ 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())
|
||||
@@ -44,6 +60,23 @@ 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")
|
||||
@@ -53,6 +86,66 @@ 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("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||
|
||||
@@ -23,7 +23,7 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360
|
||||
const sent = [];
|
||||
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
|
||||
const env = {
|
||||
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
devGen: 0, ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
|
||||
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
|
||||
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
|
||||
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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");
|
||||
@@ -8,7 +8,7 @@ const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
function page(api) {
|
||||
const pad = { state: "ready", queue: [], sending: false };
|
||||
const shown = [];
|
||||
const fns = new Function("pad", "api", "padShow", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; });
|
||||
const fns = new Function("pad", "api", "padShow", "devGen", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; }, 0);
|
||||
return { pad, shown, ...fns };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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 () => ({}), androidApps = []) {
|
||||
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
|
||||
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
|
||||
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
|
||||
: (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", "androidApps", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps);
|
||||
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 catalogue listing that is already installed offers its management page.
|
||||
{
|
||||
const p = page(undefined, [{ package: offer.package }]);
|
||||
p.sourceState.detail = { app: { name: offer.name, offers: [{ ...offer }] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== true) fail("an installed listing reported a render failure");
|
||||
if (p.els.detailInstall.textContent !== "View installed app") fail("installed listing offered another install");
|
||||
}
|
||||
|
||||
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
||||
{
|
||||
const p = page();
|
||||
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 <App>")) 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");
|
||||
+31
-1
@@ -1,12 +1,20 @@
|
||||
"""Imported first by every test module: nothing a test does reaches this person's
|
||||
app data, their telemetry, or the shared compatibility database.
|
||||
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
|
||||
|
||||
Must run before any ui module is imported, since those read these at import time.
|
||||
The variables are set in os.environ, so servers and scripts the tests start
|
||||
inherit them too (they pass {**os.environ, ...}).
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# The environment and real ~/.ssh from before the changes below: test_sandbox
|
||||
# reruns tests from that environment and checks ~/.ssh is left alone.
|
||||
ORIGINAL_ENV = dict(os.environ)
|
||||
REAL_SSH_DIR = Path.home() / ".ssh"
|
||||
|
||||
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
|
||||
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
|
||||
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
|
||||
# into the real ~/.ssh/frame-control-hosts on every run.
|
||||
#
|
||||
# tests/e2e must not import this (directly or through a test_* module): they run in
|
||||
# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
|
||||
# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
|
||||
# need live in sandbox-free modules such as apk_fixtures.py.
|
||||
HOME = os.path.join(_dir, "home")
|
||||
SSH_DIR = os.path.join(HOME, ".ssh")
|
||||
os.makedirs(SSH_DIR, mode=0o700)
|
||||
os.environ["HOME"] = HOME
|
||||
os.environ["USERPROFILE"] = HOME # Path.home() on Windows
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
|
||||
# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
|
||||
# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
|
||||
for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
|
||||
("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
|
||||
("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
|
||||
os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
|
||||
@@ -0,0 +1,219 @@
|
||||
// Production standalone scripts with deterministic DOM/network boundaries. No browser/headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const root = process.env.STANDALONE_SOURCE_ROOT || path.resolve(__dirname, '..');
|
||||
const read = name => fs.readFileSync(path.join(root, 'ui', name), 'utf8');
|
||||
const script = html => html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
const tick = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
|
||||
|
||||
function fixture(html) {
|
||||
const elements = new Map(), events = new Map(), requests = [];
|
||||
const document = {
|
||||
activeElement: null,
|
||||
getElementById: id => element(id),
|
||||
querySelector: selector => selector === 'main' ? element('main') : document.querySelectorAll(selector)[0],
|
||||
querySelectorAll: selector => [...elements.values()].flatMap(el => el.children).filter(el => el.matches(selector)),
|
||||
addEventListener: (name, fn) => { if (!events.has(name)) events.set(name, []); events.get(name).push(fn); },
|
||||
};
|
||||
class Element {
|
||||
constructor(id, attrs = '', text = '') {
|
||||
this.id = id; this.dataset = {}; this.children = []; this.hidden = false; this.open = false;
|
||||
this.isConnected = true; this.value = ''; this.checked = false; this.disabled = /\bdisabled\b/.test(attrs);
|
||||
this.textContent = text; this.attrs = {}; this.listeners = new Map();
|
||||
for (const [, key, value] of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) {
|
||||
this.attrs[key] = value;
|
||||
if (key.startsWith('data-')) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
||||
}
|
||||
for (const [, key] of attrs.matchAll(/\bdata-([\w-]+)(?=\s|$)/g)) if (!Object.hasOwn(this.dataset, key)) this.dataset[key] = '';
|
||||
}
|
||||
matches(s) {
|
||||
if (s === 'button:not(:disabled)') return this.tag === 'button' && !this.disabled;
|
||||
if (s.startsWith('#')) return this.id === s.slice(1);
|
||||
const data = s.match(/\[data-([\w-]+)(?:="([^"]*)")?\]/);
|
||||
if (data) return Object.hasOwn(this.dataset, data[1]) && (data[2] === undefined || this.dataset[data[1]] === data[2]);
|
||||
return false;
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this.html = value;
|
||||
for (const child of this.children) { child.isConnected = false; if (document.activeElement === child) document.activeElement = null; }
|
||||
this.children = [...value.matchAll(/<(button|h2)\b([^>]*)>([\s\S]*?)<\/\1>/g)].map(([, tag, attrs, text]) => {
|
||||
const child = new Element((attrs.match(/\bid="([^"]+)"/) || [])[1], attrs, text);
|
||||
child.tag = tag; child.parent = this; return child;
|
||||
});
|
||||
}
|
||||
get innerHTML() { return this.html || ''; }
|
||||
querySelectorAll(s) { return this.children.filter(el => el.matches(s)); }
|
||||
querySelector(s) { return this.querySelectorAll(s)[0] || null; }
|
||||
contains(el) { return el === this || this.children.includes(el); }
|
||||
closest(s) { return this.matches(s) ? this : this.parent?.closest(s) || null; }
|
||||
setAttribute(k, v) { this.attrs[k] = v; }
|
||||
append(el) { el.parent = this; }
|
||||
focus() { if (!this.disabled && this.isConnected) document.activeElement = this; }
|
||||
showModal() { this.open = true; this.modalOpened = true; }
|
||||
close() { this.open = false; this.onclose?.(); }
|
||||
addEventListener(name, fn) { this.listeners.set(name, fn); }
|
||||
reset() {}
|
||||
}
|
||||
function element(id) {
|
||||
const dynamic = [...elements.values()].flatMap(el => el.children).find(el => el.id === id);
|
||||
if (dynamic) return dynamic;
|
||||
if (!elements.has(id)) elements.set(id, new Element(id));
|
||||
return elements.get(id);
|
||||
}
|
||||
element('detail').hidden = element('kbd').hidden = true;
|
||||
document.body = element('body');
|
||||
// The sheet is stable; its contents are replaced by the production redraw.
|
||||
element('detail').contains = el => el === element('detail') || element('sheet').contains(el);
|
||||
const context = {document, location: {search: '', hash: ''}, URLSearchParams,
|
||||
window: {addEventListener: (name, fn) => events.set(name, [fn])},
|
||||
setTimeout: () => 1, clearTimeout() {}, Event: class {},
|
||||
fetch: (url, options) => new Promise((resolve, reject) => requests.push({url, options, resolve, reject})),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(script(html).replace('__FRAME_KEY__', JSON.stringify('ios +&?key/#')), context);
|
||||
const run = code => vm.runInContext(code, context);
|
||||
const answer = async (index, data, ok = true) => { (typeof index === 'number' ? requests[index] : index).resolve({ok, json: async () => data}); await tick(); };
|
||||
return {element, document, context, requests, run, answer, events};
|
||||
}
|
||||
|
||||
test('Assistant Back encodes the injected key for the same-origin workspace only', () => {
|
||||
const html = read('assistant.html'), f = fixture(html);
|
||||
const back = f.element('back');
|
||||
const fallback = html.match(/<a[^>]*href="([^"]*)"[^>]*>Back to Frame Control/)[1];
|
||||
const destination = new URL(back.href || fallback, 'http://127.0.0.1:8123/assistant#confirm=private');
|
||||
assert.equal(destination.origin, 'http://127.0.0.1:8123');
|
||||
assert.equal(destination.pathname, '/');
|
||||
assert.equal(destination.hash, '');
|
||||
assert.equal(destination.searchParams.get('key'), 'ios +&?key/#');
|
||||
const index = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
|
||||
const expression = index.match(/const UI_KEY = ([^;]+);/)[1];
|
||||
assert.equal(vm.runInNewContext(expression, {URLSearchParams, location: {search: destination.search}}), 'ios +&?key/#');
|
||||
for (const [, href] of html.matchAll(/href="(https?:[^\"]+)"/g)) assert.ok(!href.includes('key='));
|
||||
assert.match(html, /referrerpolicy="no-referrer"/);
|
||||
});
|
||||
|
||||
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.view = "installed"; render()');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const retry = f.element('content').querySelector('[data-retry-installed]');
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.requests[1].url, '/api/android');
|
||||
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
const failed = f.run('loadInstalled()');
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
f.requests[2].reject(Error('refresh failed')); await failed;
|
||||
assert.match(f.element('content').innerHTML, /Cached app/);
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
|
||||
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
|
||||
test('newest installed read wins over older success and failure without pruning cached data', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
|
||||
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installed.has("new.app")'), true);
|
||||
assert.equal(f.run('st.needsData.has("new.app")'), true);
|
||||
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
|
||||
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
|
||||
f.requests[3].reject(Error('late failure')); await old;
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
assert.equal(f.run('st.installedError'), '');
|
||||
assert.equal(f.run('st.installed.size'), 0);
|
||||
});
|
||||
|
||||
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.view = "installed"');
|
||||
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.equal(f.run('st.installedError'), 'latest offline');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
|
||||
test('catalogue details distinguish unknown and stale installed state', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.context.app = {name: 'Example', package: 'example.app', detailed: true, offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('openDetail(app)');
|
||||
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
const loaded = f.run('loadInstalled()'); await f.answer(f.requests.at(-1), {apps: []}); await loaded;
|
||||
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
});
|
||||
|
||||
test('named native Store dialog enters, contains and restores focus across async redraws', async () => {
|
||||
const html = read('store.html');
|
||||
assert.match(html.match(/<[^>]*id="detail"[^>]*>/)[0], /<dialog\b[^>]*aria-labelledby="detailTitle"/);
|
||||
const f = fixture(html);
|
||||
await f.answer(0, {apps: []});
|
||||
f.context.app = {name: 'Example', offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('st.view = "games"; st.apps = [app]; st.loading = false; render()');
|
||||
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
|
||||
f.run('openDetail(app)');
|
||||
assert.equal(f.element('detail').modalOpened, true);
|
||||
assert.equal(f.element('toast').parent, f.element('detail'));
|
||||
assert.equal(f.document.activeElement.id, 'detailTitle');
|
||||
const install = f.element('sheet').querySelector('[data-act="install"]'); install.focus();
|
||||
await f.answer(2, {description: 'Details arrived'}); // request 1 is bootstrap search
|
||||
assert.equal(f.document.activeElement.dataset.act, 'install');
|
||||
assert.notEqual(f.document.activeElement, install);
|
||||
const close = f.element('sheet').querySelector('[data-act="close"]'); close.focus();
|
||||
f.run('st.jobs.set("test:example", {done: false, stage: "Downloading", percent: 20}); drawDetail(); render()');
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
const keydown = f.element('detail').onkeydown || f.element('detail').listeners.get('keydown');
|
||||
const event = {key: 'Tab', shiftKey: false, preventDefault() {this.prevented = true;}};
|
||||
keydown(event);
|
||||
assert.equal(event.prevented, true);
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close'); // only remaining action
|
||||
f.run('st.jobs.delete("test:example"); drawDetail()');
|
||||
const first = f.element('sheet').querySelector('[data-act="install"]'); first.focus();
|
||||
const reverse = {key: 'Tab', shiftKey: true, preventDefault() {this.prevented = true;}}; keydown(reverse);
|
||||
assert.equal(reverse.prevented, true); assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
first.focus(); f.run('st.jobs.set("test:example", {done: false}); drawDetail()');
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close'); // focused action disappeared
|
||||
f.run('closeDetail()');
|
||||
assert.equal(f.element('detail').open, false);
|
||||
assert.equal(f.element('toast').parent, f.document.body);
|
||||
assert.equal(f.document.activeElement.dataset.i, '0');
|
||||
assert.notEqual(f.document.activeElement, opener); // render replaced the background card
|
||||
});
|
||||
|
||||
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('st.view = "games"; st.apps = [app]; render()');
|
||||
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
|
||||
f.run('openDetail(app)');
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
await f.answer(1, {apps: [f.context.app], sources: []});
|
||||
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
await f.answer(f.requests.at(-1), {apps: []});
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
|
||||
const cancel = {preventDefault() {this.prevented = true;}};
|
||||
f.element('detail').oncancel(cancel);
|
||||
assert.equal(cancel.prevented, true); assert.equal(f.element('detail').open, false);
|
||||
assert.equal(f.document.activeElement.dataset.i, '0');
|
||||
// Removing the app while details are open falls back to Search on close.
|
||||
f.run('openDetail(app); st.apps = []; render(); closeDetail()');
|
||||
assert.equal(f.document.activeElement, f.element('q'));
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -18,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
_images._cache.clear()
|
||||
_images._dns.clear()
|
||||
|
||||
def test_only_registered_source_images_are_fetchable(self):
|
||||
with patch.object(_images, 'fetch') as fetch:
|
||||
@@ -70,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
|
||||
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
|
||||
|
||||
def test_handles_are_bounded(self):
|
||||
for i in range(4100):
|
||||
_images.register('https://example.com/%d.png' % i)
|
||||
self.assertEqual(len(_images._urls),4096)
|
||||
with patch.object(_images, 'MAX_TOKENS', 50):
|
||||
first = _images.register('https://example.com/0.png')
|
||||
for i in range(1, 60):
|
||||
_images.register('https://example.com/%d.png' % i)
|
||||
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
|
||||
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
|
||||
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
|
||||
|
||||
def test_plain_language_verdict_is_evidence_based(self):
|
||||
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -121,7 +122,7 @@ class SearchTests(SettingsTest):
|
||||
break
|
||||
time.sleep(.01)
|
||||
self.assertTrue(arrived[1]['event'].wait(2))
|
||||
self.assertEqual(arrived[1]['query'], ('second', 50))
|
||||
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
|
||||
|
||||
def test_set_enabled_does_not_hold_search_lock_in_source(self):
|
||||
free = []
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Exercise the production preference bootstrap across separate web origins."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page behavior')
|
||||
class ClientPreferences(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/client_preferences.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
|
||||
self.assertIsNone(status.activity_level())
|
||||
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
|
||||
self.assertEqual(status.activity_level(), 3)
|
||||
|
||||
|
||||
class DesktopBundleTests(unittest.TestCase):
|
||||
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
|
||||
def test_sources_are_sent_once_per_version(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
|
||||
here.mkdir()
|
||||
home.mkdir()
|
||||
for name in server.COMFORT_FILES:
|
||||
(here / name).write_text('# ' + name + '\n')
|
||||
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
|
||||
sent = []
|
||||
|
||||
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
|
||||
sent.append(stdin)
|
||||
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
|
||||
env={**os.environ, 'HOME': str(home)}).stdout
|
||||
|
||||
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
|
||||
first = server.comfort({'action': 'status'})
|
||||
self.assertEqual(json.loads(first['got']), {'action': 'status'})
|
||||
self.assertEqual(len(sent), 2) # the check, then the sources
|
||||
self.assertNotIn('# frame_steam.py', sent[0])
|
||||
self.assertIn('# frame_steam.py', sent[1])
|
||||
self.assertEqual(server.comfort({'action': 'status'}), first)
|
||||
self.assertEqual(len(sent), 3)
|
||||
self.assertNotIn('# frame_steam.py', sent[2])
|
||||
cached, = (home / '.cache/frame-control/comfort').iterdir()
|
||||
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
|
||||
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
|
||||
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
|
||||
# A copy cut short is completed; a changed helper is a new version.
|
||||
(cached / 'frame_status.py').unlink()
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 5)
|
||||
self.assertTrue((cached / 'frame_status.py').exists())
|
||||
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 7)
|
||||
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -58,3 +59,32 @@ 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)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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_bundle_runs_the_server_on_its_own(self):
|
||||
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
|
||||
import io, tarfile, tempfile
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
|
||||
tar.extractall(tmp)
|
||||
result = subprocess.run([sys.executable, '-I', '-B', '-c',
|
||||
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
|
||||
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
|
||||
cwd=tmp, capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
'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}])
|
||||
@@ -264,6 +264,30 @@ 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"])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline authenticated repository fixtures; no tests contact a server."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -256,6 +257,36 @@ 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,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline artwork, Steam API and launcher supervision regressions."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -121,6 +122,11 @@ FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from apk_sources import _images
|
||||
_images._dns.clear() # each test answers lookups its own way
|
||||
self.addCleanup(_images._dns.clear)
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -263,11 +269,14 @@ 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', time.monotonic() + 5), [1])
|
||||
self.assertEqual(sgdb._get('/search/x', 'secret', deadline), [1])
|
||||
self.assertEqual(get.call_args.kwargs['redirects'], 0)
|
||||
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
|
||||
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
|
||||
# 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)
|
||||
|
||||
def test_supplied_jpeg(self):
|
||||
data = (FIXTURES / 'icon.jpg').read_bytes()
|
||||
@@ -419,6 +428,19 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
|
||||
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
|
||||
|
||||
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
|
||||
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
|
||||
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
|
||||
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
|
||||
patch.object(android, 'ssh', return_value='') as ssh, \
|
||||
patch.object(android, 'shortcut_tool') as api:
|
||||
with self.assertRaisesRegex(android.FrameError, 'timed out'):
|
||||
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
|
||||
commands = [c.args[0] for c in ssh.call_args_list]
|
||||
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
|
||||
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
|
||||
api.assert_not_called()
|
||||
|
||||
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
|
||||
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
|
||||
patch.object(android, 'stop'), \
|
||||
@@ -482,6 +504,49 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
|
||||
|
||||
|
||||
class CopyAndSshTests(unittest.TestCase):
|
||||
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
|
||||
gets the time it needs."""
|
||||
|
||||
def fake(self, stderr, code=1, stdout=b''):
|
||||
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
|
||||
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
|
||||
f'sys.exit({code})')
|
||||
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
|
||||
|
||||
def test_large_apk_copy_timeout_grows_with_its_size(self):
|
||||
seen = {}
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
|
||||
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
|
||||
android._copy(f.name, 'Applications/Android/x/app.apk.part')
|
||||
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
|
||||
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
|
||||
android._copy(f.name, 'x/launch.sh', timeout=120)
|
||||
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
|
||||
|
||||
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
|
||||
# A message from a Windows code page, or a file name, isn't UTF-8.
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.shutil, 'which', return_value=None), \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
|
||||
android._copy(f.name, 'x/app.apk.part')
|
||||
|
||||
def test_ssh_failure_messages_are_text(self):
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
|
||||
android.ssh('mv a b')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
|
||||
with self.assertRaises(android.FrameError) as caught:
|
||||
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
|
||||
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
|
||||
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
|
||||
|
||||
|
||||
class SteamAPITests(unittest.TestCase):
|
||||
def test_artwork_api_enums_and_safe_serialization(self):
|
||||
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
||||
|
||||
+15
-76
@@ -11,82 +11,7 @@ import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
|
||||
|
||||
|
||||
class ApkInfo(unittest.TestCase):
|
||||
@@ -168,6 +93,20 @@ class ApkInfo(unittest.TestCase):
|
||||
frame_apk.MAX_MANIFEST = limit
|
||||
self.assertLess(peak, 8 * 1024**2)
|
||||
|
||||
def test_damaged_download_is_an_apk_error(self):
|
||||
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
|
||||
# (a 500, and no install report) and frame_android.install reported as 'other'.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
|
||||
data = bytearray(buf.getvalue())
|
||||
at = data.index(b'PK\x03\x04')
|
||||
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
|
||||
for i in range(start, start + 40):
|
||||
data[i] ^= 0x5a
|
||||
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
|
||||
self.read(bytes(data))
|
||||
|
||||
def test_refuses_compression_android_cant_read(self):
|
||||
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -184,6 +184,35 @@ 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')), \
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -15,7 +16,7 @@ import frame_apk
|
||||
import frame_apk_vr as vr
|
||||
import frame_apk_sign as signing
|
||||
import frame_android
|
||||
from test_frame_apk import pool
|
||||
from apk_fixtures import pool
|
||||
|
||||
|
||||
DEFAULT = 'android.intent.category.DEFAULT'
|
||||
@@ -255,6 +256,10 @@ 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)), {})
|
||||
@@ -269,8 +274,82 @@ 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.FrameError, 'build.sh'):
|
||||
with self.assertRaisesRegex(frame_android.LayerMissing, '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')):
|
||||
|
||||
@@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""iPhone packaging and Foundation contracts; no headset, downloads or Xcode build."""
|
||||
import sandbox # noqa: F401
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class BundleContracts(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
spec = importlib.util.spec_from_file_location("ios_bundle_contract", ROOT / "ios/scripts/make_frame_bundle.py")
|
||||
cls.bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.bundle)
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
cls.root = Path(cls.tmp.name)
|
||||
with tarfile.open(fileobj=io.BytesIO(cls.bundle.build()), mode="r:gz") as archive:
|
||||
archive.extractall(cls.root)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def test_required_resources_ship_to_both_clients(self):
|
||||
resources = ["frame/android/library_artwork.js", "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"]
|
||||
config = json.loads((ROOT / "app/package.json").read_text())
|
||||
for resource in resources:
|
||||
with self.subTest(resource=resource):
|
||||
self.assertTrue((self.root / resource).is_file(), resource)
|
||||
# Match the desktop's real resource filters as well as the phone archive.
|
||||
packaged = False
|
||||
for entry in config["build"]["extraResources"]:
|
||||
source = entry["from"].removeprefix("../")
|
||||
if Path(resource).is_relative_to(source):
|
||||
relative = str(Path(resource).relative_to(source))
|
||||
packaged |= any(fnmatch.fnmatch(relative, pattern) for pattern in entry.get("filter", ["**/*"]))
|
||||
self.assertTrue(packaged, "desktop resource contract: " + resource)
|
||||
|
||||
def test_extracted_runtime_can_render_and_load_xr_layer(self):
|
||||
result = subprocess.run([sys.executable, "-I", "-B", "-c", """
|
||||
import sys, zipfile
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0, 'ui')
|
||||
import frame_android as android
|
||||
with patch.object(android, 'ssh', return_value='rendered') as ssh:
|
||||
assert android.shortcut_tool('render', '/synthetic/app') == 'rendered'
|
||||
assert 'ART_RENDERER = ' in ssh.call_args.kwargs['input']
|
||||
assert len(ssh.call_args.kwargs['input']) > 1000
|
||||
with zipfile.ZipFile('synthetic.apk', 'w') as apk:
|
||||
apk.writestr('lib/arm64-v8a/libopenxr_loader.so', b'synthetic loader')
|
||||
files = android.xr_compat_files('synthetic.apk')
|
||||
assert set(files) == set(android.XR_COMPAT_FILES)
|
||||
assert files['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\\x7fELF')
|
||||
"""], cwd=self.root, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
class NativeContracts(unittest.TestCase):
|
||||
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS Swift compiles actual Foundation production sources")
|
||||
def test_foundation_contracts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
main = Path(tmp) / "main.swift"
|
||||
main.write_text("import XCTest\nlet suite = XCTestSuite(forTestCaseClass: NativeSessionTests.self)\nsuite.run()\nexit(suite.testRun!.hasSucceeded ? 0 : 1)\n")
|
||||
executable = Path(tmp) / "native-contracts"
|
||||
developer = subprocess.check_output(["xcode-select", "-p"], text=True).strip()
|
||||
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
|
||||
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
|
||||
shared = str(Path(developer).parent / "SharedFrameworks")
|
||||
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
|
||||
"-Xlinker", "-rpath", "-Xlinker", libraries,
|
||||
"-Xlinker", "-rpath", "-Xlinker", shared,
|
||||
"-D", "NATIVE_CONTRACT_STANDALONE",
|
||||
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
||||
str(ROOT / "ios/FrameControlTests/NativeSessionTests.swift"),
|
||||
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("Executed 5 tests", result.stdout + result.stderr)
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin" and shutil.which("swiftc"), "macOS exercises the production finder with controlled probes")
|
||||
def test_finder_ignores_late_probes_after_edit_and_stop(self):
|
||||
harness = r'''import Foundation
|
||||
actor Probes {
|
||||
var pending: [String: CheckedContinuation<Bool, Never>] = [:]
|
||||
func probe(_ host: String) async -> Bool {
|
||||
await withCheckedContinuation { pending[host] = $0 }
|
||||
}
|
||||
func has(_ host: String) -> Bool { pending[host] != nil }
|
||||
func answer(_ host: String) { pending.removeValue(forKey: host)!.resume(returning: true) }
|
||||
}
|
||||
@main struct FinderContracts {
|
||||
@MainActor static func wait(_ condition: () async -> Bool) async {
|
||||
for _ in 0..<100 {
|
||||
if await condition() { return }
|
||||
try! await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
fatalError("controlled callback did not run")
|
||||
}
|
||||
@MainActor static func main() async {
|
||||
let probes = Probes()
|
||||
let finder = FrameFinder(probe: { await probes.probe($0) }, discoveryEnabled: false)
|
||||
finder.start(fallback: "old.local")
|
||||
await wait { await probes.has("old.local") }
|
||||
finder.start(fallback: "100.100.100.2:2222", manual: true)
|
||||
await wait { await probes.has("100.100.100.2:2222") }
|
||||
await probes.answer("old.local")
|
||||
// Let the cancelled task return its deliberately late successful answer.
|
||||
try! await Task.sleep(nanoseconds: 20_000_000)
|
||||
precondition(finder.found == nil, "A stale probe replaced the edited target")
|
||||
await probes.answer("100.100.100.2:2222")
|
||||
await wait { finder.found != nil }
|
||||
precondition(finder.found?.host == "100.100.100.2:2222")
|
||||
finder.start(fallback: "stopped.local", manual: true)
|
||||
await wait { await probes.has("stopped.local") }
|
||||
finder.stop()
|
||||
await probes.answer("stopped.local")
|
||||
try! await Task.sleep(nanoseconds: 20_000_000)
|
||||
precondition(finder.found == nil, "A stopped search published a result")
|
||||
print("Production FrameFinder: stale and cancelled probes ignored; manual target retained")
|
||||
}
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
main = Path(tmp) / "FinderContracts.swift"
|
||||
main.write_text(harness)
|
||||
executable = Path(tmp) / "finder-contracts"
|
||||
result = subprocess.run(["swiftc", "-parse-as-library",
|
||||
str(ROOT / "ios/FrameControl/App/NativeSessionState.swift"),
|
||||
str(ROOT / "ios/FrameControl/SSH/FrameFinder.swift"),
|
||||
str(main), "-o", str(executable)], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node executes the unmodified production bridge script")
|
||||
def test_bridge_preferences_and_document_acknowledgement(self):
|
||||
source = (ROOT / "ios/FrameControl/Web/WebShell.swift").read_text()
|
||||
bridge = source.split('static let bridge = """', 1)[1].split('"""', 1)[0]
|
||||
script = r'''const vm = require('node:vm');
|
||||
const assert = require('node:assert/strict');
|
||||
const calls = [];
|
||||
const window = {webkit: {messageHandlers: {frameApp: {postMessage: body => {calls.push(body); return Promise.resolve(true)}}}}};
|
||||
vm.runInNewContext(process.argv[1], {window, crypto: {randomUUID: () => 'document-42'}});
|
||||
assert.equal(window.__frameInstallLink({}), false);
|
||||
window.frameApp.preferences.read();
|
||||
window.frameApp.preferences.write('mvSeparate', '0');
|
||||
assert.equal(JSON.stringify(calls), JSON.stringify([
|
||||
{name: 'preferencesRead', arg: null}, {name: 'preferencesWrite', arg: {key: 'mvSeparate', value: '0'}}
|
||||
]));
|
||||
const received = [];
|
||||
window.frameApp.onInstallLink(req => received.push(req));
|
||||
assert.equal(calls.at(-1).name, 'installLinkReady');
|
||||
assert.equal(calls.at(-1).arg, 'document-42');
|
||||
assert.equal(window.__frameInstallLink({target: 'a'}), true);
|
||||
assert.equal(received[0].target, 'a');
|
||||
window.frameApp.onInstallLink(() => {throw new Error('callback failed')});
|
||||
assert.throws(() => window.__frameInstallLink({}), /callback failed/);
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", script, bridge], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Every public sideload entry point reaches the mandatory five-slot writer."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
|
||||
+699
-5
@@ -6,8 +6,10 @@ 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
|
||||
@@ -44,6 +46,17 @@ def explain(msg):
|
||||
return None
|
||||
|
||||
|
||||
class FailureDetail(unittest.TestCase):
|
||||
def test_an_address_that_failed_says_why_in_a_few_words(self):
|
||||
# #87: the banner put each address's detail after the headline, and with the whole
|
||||
# message as the detail it said everything twice ("...may be answering here..").
|
||||
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
|
||||
"SSH identity changed")
|
||||
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
|
||||
"The connection to the Frame dropped. Try again.")
|
||||
self.assertEqual(fl.failure_detail(None), "SSH failed")
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
@@ -198,7 +211,8 @@ class Connecting(unittest.TestCase):
|
||||
rows = {p["host"]: p for p in s["probes"]}
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||
self.assertIn("different headset", rows["::1"]["detail"])
|
||||
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
|
||||
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
|
||||
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-t")
|
||||
@@ -207,9 +221,10 @@ 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
|
||||
# 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.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)
|
||||
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"]}
|
||||
@@ -256,6 +271,9 @@ 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")
|
||||
@@ -265,6 +283,428 @@ 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"})
|
||||
@@ -500,7 +940,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": "forget-identity", "id": d["id"]}):
|
||||
{"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:x"}):
|
||||
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.
|
||||
@@ -599,6 +1039,260 @@ 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):
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
|
||||
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def between(start, end):
|
||||
return PAGE[PAGE.index(start):PAGE.index(end)]
|
||||
|
||||
|
||||
SETUP = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
const serverNow = () => 1000;
|
||||
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
|
||||
const ago = () => 'a minute ago';
|
||||
const openTrust = () => {};
|
||||
const window = {};
|
||||
let online = false;
|
||||
const link = {live: true, s: null};
|
||||
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
|
||||
let rendered = 0;
|
||||
const renderDevices = () => { rendered++; };
|
||||
let refuse = false;
|
||||
const act = async (label, fn) => refuse ? undefined : fn();
|
||||
const api = async () => ({ok: true});
|
||||
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
|
||||
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
|
||||
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
|
||||
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
|
||||
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
|
||||
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
|
||||
+ 'If not, a different device may be answering here.'}};
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
|
||||
class LinkUI(unittest.TestCase):
|
||||
def run_page(self, checks):
|
||||
code = (between("function netLabel(s) {", "function renderConnDlg() {")
|
||||
+ between("async function useDevice(id) {", "async function devAction(")
|
||||
+ between("function deviceStatus(d) {", "function renderDevices() {"))
|
||||
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
|
||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_the_pill_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'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()
|
||||
@@ -7,6 +7,7 @@ input reaching the source, and closing.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,122 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_connect # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
|
||||
|
||||
def inside(path, folder):
|
||||
path, folder = os.path.realpath(path), os.path.realpath(folder)
|
||||
return path == folder or path.startswith(folder + os.sep)
|
||||
|
||||
|
||||
def snapshot(folder):
|
||||
"""{relative path: (size, mtime)} for everything under FOLDER."""
|
||||
out = {}
|
||||
for base, dirs, files in os.walk(folder):
|
||||
for name in dirs + files:
|
||||
p = os.path.join(base, name)
|
||||
try:
|
||||
st = os.lstat(p)
|
||||
except OSError:
|
||||
continue
|
||||
out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns)
|
||||
return out
|
||||
|
||||
|
||||
class Sandbox(unittest.TestCase):
|
||||
"""Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every
|
||||
run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config)."""
|
||||
|
||||
def test_home_and_ssh_dir_are_throwaway(self):
|
||||
tmp = tempfile.gettempdir()
|
||||
self.assertTrue(inside(Path.home(), tmp), Path.home())
|
||||
self.assertTrue(inside(os.path.expanduser("~"), tmp))
|
||||
for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR,
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"], frame_host.cache_dir(), frame_host.data_dir()):
|
||||
self.assertTrue(inside(d, tmp), d)
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
|
||||
def test_cache_folders_on_every_platform_are_throwaway(self):
|
||||
"""cache_dir() reads LOCALAPPDATA on Windows and XDG_CACHE_HOME on Linux, which a
|
||||
moved HOME alone doesn't cover: servers sweep staging folders there."""
|
||||
tmp = tempfile.gettempdir()
|
||||
for platform in ("MAC", "WINDOWS", None):
|
||||
with mock.patch.object(frame_host, "MAC", platform == "MAC"), \
|
||||
mock.patch.object(frame_host, "WINDOWS", platform == "WINDOWS"):
|
||||
self.assertTrue(inside(frame_host.cache_dir("apk-sources"), tmp), platform)
|
||||
for name in ("LOCALAPPDATA", "APPDATA", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
|
||||
self.assertTrue(inside(os.environ[name], tmp), name)
|
||||
|
||||
def test_end_to_end_tests_stay_out_of_it(self):
|
||||
"""tests/e2e keeps the container's real home (OpenSSH reads ~/.ssh from the password
|
||||
database, not $HOME), so nothing there may import sandbox, even through a test_* module."""
|
||||
here = Path(__file__).resolve().parent
|
||||
pattern = re.compile(r"^\s*(import sandbox|from sandbox |import test_|from test_)", re.M)
|
||||
for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]:
|
||||
self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f)
|
||||
|
||||
def test_end_to_end_tests_start_no_server_outside_their_container(self):
|
||||
"""Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything
|
||||
and never start ui/server.py: outside scripts/e2e.sh's container it would run on
|
||||
this computer's real app data, ~/.ssh and cache."""
|
||||
e2e = str(Path(__file__).resolve().parent / "e2e")
|
||||
code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n"
|
||||
"harness.Server.start = classmethod(lambda cls: calls.append(cls))\n"
|
||||
"suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n"
|
||||
"r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n"
|
||||
"print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))")
|
||||
for extra in ({}, {"FRAME_E2E": "1"}):
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")}
|
||||
env.update(extra, PYTHONDONTWRITEBYTECODE="1")
|
||||
out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True,
|
||||
timeout=120)
|
||||
self.assertEqual(out.returncode, 0, out.stderr[-2000:])
|
||||
started, ran, skipped, broken = map(int, out.stdout.split())
|
||||
self.assertEqual((started, broken), (0, 0), (extra, out.stdout))
|
||||
self.assertEqual(ran, skipped, (extra, out.stdout))
|
||||
self.assertGreater(skipped, 0, extra)
|
||||
|
||||
def test_child_processes_inherit_it(self):
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"
|
||||
" from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)",
|
||||
str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
capture_output=True, text=True, check=True).stdout.split("\n")
|
||||
for d in filter(None, out):
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
self.assertTrue(inside(d, tempfile.gettempdir()), d)
|
||||
|
||||
def test_real_ssh_folder_untouched_by_tests_that_start_servers(self):
|
||||
"""Reruns, from the environment the suite was started in, the tests that used to
|
||||
leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect
|
||||
tests, and checks nothing under the real ~/.ssh changed."""
|
||||
real = sandbox.REAL_SSH_DIR
|
||||
if inside(real, tempfile.gettempdir()):
|
||||
self.skipTest("this run's HOME is already a temporary folder")
|
||||
before = snapshot(real)
|
||||
env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"}
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode",
|
||||
"test_devices", "test_connect"],
|
||||
cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-3000:])
|
||||
after = snapshot(real)
|
||||
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)
|
||||
and not k.startswith("agent")) # ssh-agent sockets come and go on their own
|
||||
self.assertEqual(changed, [], f"tests changed the real {real}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+186
-10
@@ -10,6 +10,7 @@ import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
@@ -78,6 +79,21 @@ 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_store_page_served_with_anti_framing_headers(self):
|
||||
status, headers, payload = self.request("GET", "/store")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<title>APK Store</title>", payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
@@ -287,15 +303,77 @@ class OneServer(unittest.TestCase):
|
||||
|
||||
@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."""
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid"}
|
||||
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)
|
||||
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
"""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):
|
||||
@@ -348,6 +426,59 @@ 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)
|
||||
|
||||
|
||||
@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
|
||||
@@ -419,7 +550,11 @@ 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 changed",
|
||||
"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",
|
||||
"kex_exchange_identification: read: Connection reset by peer": "dropped",
|
||||
"Timed out talking to frame": "too long",
|
||||
}
|
||||
@@ -435,6 +570,47 @@ 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")
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
"""Offline production-seam coverage for SERVICES01–04; no headset or provider calls."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import server
|
||||
import frame_agent as agent
|
||||
import frame_android as android
|
||||
|
||||
|
||||
class TargetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.active = {'id': 'a', 'name': 'Headset A'}
|
||||
self.link = mock.Mock()
|
||||
self.link.active_device.side_effect = lambda: dict(self.active)
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.stack.enter_context(mock.patch.object(server, 'LINK', self.link))
|
||||
self.stack.enter_context(mock.patch.object(server, 'PRIVATE', False))
|
||||
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
|
||||
self.stack.enter_context(mock.patch.object(server, '_backfill', {'running': False, 'last': 0.0}))
|
||||
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
|
||||
self.stack.enter_context(mock.patch.object(server._input, 'stop'))
|
||||
self.stack.enter_context(mock.patch.object(server.frame_link, 'devices_action', self.switch))
|
||||
|
||||
def switch(self, link, body, setup, busy):
|
||||
if busy():
|
||||
raise server.frame_devices.DeviceError('Wait for work')
|
||||
self.active.update(id='b', name='Headset B')
|
||||
return {'message': 'switched'}
|
||||
|
||||
def test_route_closes_touch_and_never_sends_to_old_process(self):
|
||||
touch = server.TouchAgent()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
touch.proc, touch.status = proc, {'state': 'ready'}
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
try:
|
||||
with mock.patch.object(server, '_touch', touch), mock.patch.object(touch, 'start'):
|
||||
server.route('headset-b', [])
|
||||
result = server.remote_touch({'events': [{'dx': 17, 'dy': -4}]})
|
||||
self.assertFalse(result['sent'])
|
||||
self.assertEqual(touch.status['state'], 'off')
|
||||
proc.stdin.write.assert_not_called()
|
||||
self.assertTrue(proc.stdin.close.called or proc.terminate.called)
|
||||
finally:
|
||||
server.route(*before)
|
||||
|
||||
def test_route_invalidates_touch_start_already_inside_popen(self):
|
||||
touch = server.TouchAgent()
|
||||
touch.launching = touch.generation
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
def popen(*a, **kw):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError('test did not release launch')
|
||||
return proc
|
||||
with mock.patch.object(server, '_touch', touch), \
|
||||
mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(server.subprocess, 'Popen', side_effect=popen), \
|
||||
mock.patch.object(touch, '_watch', return_value=(False, True)):
|
||||
thread = threading.Thread(target=touch._launch, args=(0,))
|
||||
thread.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(2))
|
||||
server.route('headset-b', [])
|
||||
release.set()
|
||||
thread.join(2)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertIsNone(touch.proc)
|
||||
self.assertEqual(touch.status['state'], 'off')
|
||||
self.assertTrue(proc.terminate.called or proc.stdin.close.called)
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(2)
|
||||
server._live_tunnels.discard(proc)
|
||||
server.route(*before)
|
||||
|
||||
def test_backfill_counts_work_until_error_cleanup(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
writes = []
|
||||
def check(*a, **kw):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError('test did not release backfill')
|
||||
def refresh(*a, **kw):
|
||||
writes.append(self.active['id'])
|
||||
raise RuntimeError('art failure')
|
||||
original = server.threading.Thread
|
||||
threads = []
|
||||
def thread_factory(*a, **kw):
|
||||
thread = original(*a, **kw)
|
||||
threads.append(thread)
|
||||
return thread
|
||||
with mock.patch.object(server.frame_android, 'shortcut_tool', side_effect=check), \
|
||||
mock.patch.object(server.frame_android, 'refresh_art', side_effect=refresh), \
|
||||
mock.patch.object(server.threading, 'Thread', side_effect=thread_factory):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
try:
|
||||
self.assertTrue(entered.wait(2))
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
finally:
|
||||
release.set()
|
||||
for thread in threads:
|
||||
thread.join(2)
|
||||
self.assertEqual(writes, ['a'])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
|
||||
def test_listing_cannot_switch_before_backfill_registration(self):
|
||||
handler = server.Handler.__new__(server.Handler)
|
||||
handler.path = '/api/android'
|
||||
handler.local_request = lambda: True
|
||||
handler.send_json = mock.Mock()
|
||||
handler.send_error_json = mock.Mock()
|
||||
attempts = []
|
||||
def listing():
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
return []
|
||||
with mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(android, 'list_apps', side_effect=listing), \
|
||||
mock.patch.object(server, 'backfill_art') as backfill, \
|
||||
mock.patch.object(server.apk_search, 'artwork_for', return_value={}):
|
||||
handler.do_GET()
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
backfill.assert_called_once()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_failed_worker_start_releases_count_and_throttle(self):
|
||||
with mock.patch.object(server.threading.Thread, 'start', side_effect=RuntimeError('cannot start')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'cannot start'):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
self.assertEqual(server._backfill['last'], 0)
|
||||
|
||||
def test_backfill_refuses_identifiers_from_an_old_listing_target(self):
|
||||
self.active.update(id='b', name='Headset B')
|
||||
with mock.patch.object(android, 'refresh_art') as refresh:
|
||||
with self.assertRaises(server.Failure):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}], meant='a')
|
||||
refresh.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertFalse(server._backfill['running'])
|
||||
|
||||
def test_failed_thread_constructor_releases_count_and_throttle(self):
|
||||
with mock.patch.object(server.threading, 'Thread', side_effect=RuntimeError('thread allocation')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'thread allocation'):
|
||||
server.backfill_art(apps=[{'package': 'org.test.app', 'art_pending': True}])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
self.assertEqual(server._backfill, {'running': False, 'last': 0})
|
||||
|
||||
def test_approval_same_device_runs_once_and_releases_work_on_error(self):
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), \
|
||||
mock.patch.object(server, 'launch', side_effect=RuntimeError('launch failed')) as launch:
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
with self.assertRaisesRegex(RuntimeError, 'launch failed'):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
with self.assertRaises(ValueError):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
launch.assert_called_once()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_approval_records_target_and_rejects_switch(self):
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch') as launch:
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
self.assertEqual(approval['action']['target'], self.active)
|
||||
self.assertEqual(agent.approvals.inspect(approval['confirmation'])['action']['target'], self.active)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
with self.assertRaises(ValueError):
|
||||
agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
launch.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_approval_dispatch_holds_existing_work_guard(self):
|
||||
attempts = []
|
||||
def launch(args):
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
return {'message': 'launched'}
|
||||
with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'launch', side_effect=launch):
|
||||
body = {'name': 'launch', 'arguments': {'appid': '620'}}
|
||||
approval = agent.call(server, body)
|
||||
agent.approvals.decide(approval['confirmation'], True)
|
||||
result = agent.call(server, dict(body, confirmation=approval['confirmation']))
|
||||
self.assertEqual(result['message'], 'launched')
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
self.assertEqual(self.active['id'], 'a')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
|
||||
class FinalLaunchTests(unittest.TestCase):
|
||||
"""Production delivery/cleanup with separate temporary A/B remote homes."""
|
||||
def setUp(self):
|
||||
self.stack = contextlib.ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
|
||||
self.before = (server.FRAME, list(server.HOST_OPTS))
|
||||
self.stack.enter_context(mock.patch.object(server, 'LOCAL', False))
|
||||
self.stack.enter_context(mock.patch.object(server, 'LINK', None))
|
||||
self.stack.enter_context(mock.patch.object(server, '_work', [0]))
|
||||
self.stack.enter_context(mock.patch.object(server.macview, 'retarget'))
|
||||
self.stack.enter_context(mock.patch.object(server, '_touch'))
|
||||
self.stack.enter_context(mock.patch.object(server, 'ensure_master'))
|
||||
local = self.root / 'local'
|
||||
(local / 'packages').mkdir(parents=True)
|
||||
packages = []
|
||||
for n in (1, 2):
|
||||
name, data = 'test%d.pkg.tar.zst' % n, ('offline package %d' % n).encode()
|
||||
(local / 'packages' / name).write_bytes(data)
|
||||
packages.append((name, hashlib.sha256(data).hexdigest()))
|
||||
self.stack.enter_context(mock.patch.object(server, 'KDECONNECT', local))
|
||||
self.agent = server.InputAgent(packages=packages)
|
||||
self.stack.enter_context(mock.patch.object(server, '_input', self.agent))
|
||||
self.stack.callback(lambda: server.route(*self.before))
|
||||
for alias in ('target-a', 'target-b'):
|
||||
(self.root / alias).mkdir()
|
||||
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
|
||||
self.original_route = tuple([*server.SSH, server.FRAME])
|
||||
self.calls = []
|
||||
self.real_popen = subprocess.Popen
|
||||
|
||||
def remote(self, argv, **kw):
|
||||
target, command = argv[-2:]
|
||||
self.assertIn(target, ('target-a', 'target-b'))
|
||||
feed = kw.get('input')
|
||||
if isinstance(feed, str):
|
||||
feed = feed.encode()
|
||||
home = self.root / target
|
||||
proc = self.real_popen(['sh', '-c', command], cwd=home,
|
||||
env={**os.environ, 'HOME': str(home)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
out, err = proc.communicate(feed, timeout=3)
|
||||
self.calls.append({'route': tuple(argv[:-1]), 'command': command, 'input': feed})
|
||||
return subprocess.CompletedProcess(argv, proc.returncode,
|
||||
out.decode() if kw.get('text') else out,
|
||||
err.decode() if kw.get('text') else err)
|
||||
|
||||
def delivery_boundary(self, boundary, cancel=False):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
def remote(argv, **kw):
|
||||
result = self.remote(argv, **kw)
|
||||
command = argv[-1]
|
||||
hit = ((boundary == 'stamp' and 'cmp -s' in command)
|
||||
or (boundary == 'mkdir' and command.startswith('mkdir -p'))
|
||||
or (boundary == 'copy' and command.startswith('cd ')))
|
||||
if hit and not entered.is_set():
|
||||
entered.set()
|
||||
if not release.wait(3):
|
||||
raise AssertionError('delivery barrier not released')
|
||||
return result
|
||||
self.agent.launching = self.agent.generation
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen') as launch:
|
||||
worker = threading.Thread(target=self.agent._launch, args=(self.agent.generation,))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(3))
|
||||
if cancel:
|
||||
self.agent.stop()
|
||||
else:
|
||||
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
|
||||
release.set()
|
||||
worker.join(3)
|
||||
self.assertFalse(worker.is_alive())
|
||||
launch.assert_not_called()
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(3)
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
copies = [c for c in self.calls if c['command'].startswith('cd ')]
|
||||
self.assertEqual(len(copies), 1 if boundary == 'copy' else 0)
|
||||
for alias in ('target-a', 'target-b'):
|
||||
self.assertFalse(list((self.root / alias).rglob('*.part')))
|
||||
self.assertFalse(list((self.root / alias).glob('**/incoming/*')))
|
||||
self.assertIsNone(self.agent.proc)
|
||||
self.assertEqual(self.agent.status['state'], 'off')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_switch_after_stamp_cannot_deliver_to_b(self):
|
||||
self.delivery_boundary('stamp')
|
||||
|
||||
def test_switch_after_mkdir_cleans_only_a(self):
|
||||
self.delivery_boundary('mkdir')
|
||||
|
||||
def test_switch_after_first_copy_stops_next_copy_and_cleans_a(self):
|
||||
self.delivery_boundary('copy')
|
||||
|
||||
def test_cancel_after_first_copy_stops_next_copy_and_cleans_a(self):
|
||||
self.delivery_boundary('copy', cancel=True)
|
||||
|
||||
def test_deferred_no_output_cleanup_keeps_the_original_route(self):
|
||||
watching, release = threading.Event(), threading.Event()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.return_value = 0
|
||||
def stdout():
|
||||
watching.set()
|
||||
if not release.wait(3):
|
||||
raise AssertionError('watch barrier not released')
|
||||
yield from ()
|
||||
proc.stdout = stdout()
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen', return_value=proc):
|
||||
self.agent.start()
|
||||
try:
|
||||
self.assertTrue(watching.wait(3))
|
||||
self.assertEqual(server._work[0], 0, 'watch lifetime must not block device switching')
|
||||
server.route('target-b', ['-o', 'HostName=192.0.2.2'])
|
||||
release.set()
|
||||
# The real watch completes and discard runs after route publication.
|
||||
for _ in range(150):
|
||||
if any(c['command'].startswith('rm -rf') for c in self.calls):
|
||||
break
|
||||
threading.Event().wait(.01)
|
||||
self.assertTrue(any(c['command'].startswith('rm -rf') for c in self.calls))
|
||||
finally:
|
||||
release.set()
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
self.assertFalse(list((self.root / 'target-a').glob('**/incoming/*')))
|
||||
self.assertIsNone(self.agent.proc)
|
||||
self.assertEqual(self.agent.status['state'], 'off')
|
||||
def test_package_retry_keeps_the_original_ssh_route(self):
|
||||
first, second = mock.Mock(), mock.Mock()
|
||||
first.stdout = [b'{"state":"need-packages"}\n']
|
||||
second.stdout = [b'{"state":"ready"}\n']
|
||||
first.poll.return_value = second.poll.return_value = 0
|
||||
first.wait.side_effect = lambda: server.route('target-a', ['-o', 'HostName=192.0.2.2'])
|
||||
second.wait.return_value = 0
|
||||
with mock.patch.object(server.frame_host, 'run_ssh', side_effect=self.remote), \
|
||||
mock.patch.object(server.subprocess, 'Popen', side_effect=[first, second]) as popen:
|
||||
self.agent.launching = self.agent.generation
|
||||
self.agent._launch(self.agent.generation)
|
||||
self.assertEqual(popen.call_count, 2)
|
||||
self.assertTrue(all(tuple(call.args[0][:-1]) == self.original_route for call in popen.call_args_list))
|
||||
self.assertTrue(all(c['route'] == self.original_route for c in self.calls), self.calls)
|
||||
self.assertEqual(len([c for c in self.calls if c['command'].startswith('cd ')]), 4)
|
||||
|
||||
|
||||
|
||||
class FinalGetStartTests(unittest.TestCase):
|
||||
setUp = TargetTests.setUp
|
||||
switch = TargetTests.switch
|
||||
def handler(self, path, meant):
|
||||
handler = server.Handler.__new__(server.Handler)
|
||||
handler.command, handler.path = 'GET', path
|
||||
handler.headers = {'Host': '127.0.0.1:8123', 'X-Frame-UI': server.UI_KEY}
|
||||
if meant is not None:
|
||||
handler.headers['X-Frame-Device'] = meant
|
||||
handler.send_json, handler.send_error_json = mock.Mock(), mock.Mock()
|
||||
return handler
|
||||
|
||||
def test_stale_authenticated_get_start_is_rejected_before_start(self):
|
||||
self.active.update(id='b', name='Headset B')
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/%s?start=1' % name, 'a')
|
||||
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(handler.send_error_json.call_args.args[1], 409)
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_desktop_get_start_requires_an_intended_device(self):
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/%s?start=1' % name, None)
|
||||
with mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
start.assert_not_called()
|
||||
self.assertEqual(handler.send_error_json.call_args.args[1], 400)
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_get_start_validation_and_thread_registration_are_one_reservation(self):
|
||||
attempts = []
|
||||
fresh = server.TouchAgent()
|
||||
def register():
|
||||
try:
|
||||
server.devices_post({'action': 'use', 'id': 'b'})
|
||||
except server.Failure:
|
||||
attempts.append('refused')
|
||||
with mock.patch.object(server, '_touch', fresh), mock.patch.object(fresh, 'start', side_effect=register):
|
||||
handler = self.handler('/api/touch?start=1', 'a')
|
||||
handler.do_GET()
|
||||
self.assertEqual(attempts, ['refused'])
|
||||
self.assertEqual(self.active['id'], 'a')
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_delayed_launch_uses_the_synchronously_captured_route(self):
|
||||
pending = []
|
||||
class QueuedThread:
|
||||
def __init__(self, target, args=(), kwargs=None, **ignored):
|
||||
self.target, self.args, self.kwargs = target, args, kwargs or {}
|
||||
def start(self):
|
||||
pending.append(self)
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
try:
|
||||
for switched in (False, True):
|
||||
with self.subTest(switched=switched):
|
||||
fresh = server.TouchAgent()
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
with mock.patch.object(server, '_touch', fresh), \
|
||||
mock.patch.object(server.threading, 'Thread', QueuedThread), \
|
||||
mock.patch.object(server, 'ensure_master'), \
|
||||
mock.patch.object(fresh, '_watch', return_value=(False, True)), \
|
||||
mock.patch.object(server.subprocess, 'Popen', return_value=proc) as popen:
|
||||
server.route('target-a', ['-o', 'HostName=192.0.2.1'])
|
||||
original = tuple([*server.SSH, server.FRAME])
|
||||
handler = self.handler('/api/touch?start=1', 'a')
|
||||
handler.do_GET()
|
||||
self.assertEqual(len(pending), 1)
|
||||
# Also change options without a generation change: this
|
||||
# proves the route was captured before the thread runs.
|
||||
server.route('target-b' if switched else 'target-a', ['-o', 'HostName=192.0.2.2'])
|
||||
thread = pending.pop()
|
||||
thread.target(*thread.args, **thread.kwargs)
|
||||
if switched:
|
||||
popen.assert_not_called()
|
||||
self.assertIsNone(fresh.proc)
|
||||
else:
|
||||
self.assertEqual(tuple(popen.call_args.args[0][:-1]), original)
|
||||
fresh.stop()
|
||||
server._live_tunnels.discard(proc)
|
||||
finally:
|
||||
server.route(*before)
|
||||
|
||||
def test_same_target_start_status_and_local_ios_remain_usable(self):
|
||||
for link, meant, query in ((self.link, 'a', '?start=1'), (self.link, 'stale', ''),
|
||||
(None, None, '?start=1')):
|
||||
for name in ('input', 'touch'):
|
||||
with self.subTest(name=name, query=query, local=link is None):
|
||||
fresh = server.InputAgent(packages=[]) if name == 'input' else server.TouchAgent()
|
||||
handler = self.handler('/api/' + name + query, meant)
|
||||
with mock.patch.object(server, 'LINK', link), mock.patch.object(server, 'LOCAL', link is None), \
|
||||
mock.patch.object(server, '_' + name, fresh), mock.patch.object(fresh, 'start') as start:
|
||||
handler.do_GET()
|
||||
self.assertEqual(start.call_count, 1 if query else 0)
|
||||
handler.send_error_json.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
|
||||
class UpdateTests(unittest.TestCase):
|
||||
"""Execute generated remote commands in a temporary home; only Steam is synthetic."""
|
||||
def exercise(self, failure, repair_failure=None):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
old = {'package': 'org.test.app', 'version': '1', 'label': 'Old', 'instance': 2800000001,
|
||||
'shortcut': 42, 'flatscreen': False, 'artwork': {}}
|
||||
files = {'app.apk': b'old APK', 'launch.sh': b'old launcher', 'instance.id': b'2800000001',
|
||||
'meta.json': json.dumps(old).encode(), 'shortcut.id': b'42', 'user-save': b'precious data'}
|
||||
for name, data in files.items():
|
||||
(d / name).write_bytes(data)
|
||||
(d / 'launch.sh').chmod(0o755)
|
||||
save = root / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
||||
save.parent.mkdir(parents=True)
|
||||
save.write_bytes(b'Android saved data')
|
||||
apk = root / 'new.apk'
|
||||
apk.write_bytes(b'new APK')
|
||||
info = {'package': 'org.test.app', 'version': '2', 'label': 'New', 'vr': False}
|
||||
writes = []
|
||||
repairing = [False]
|
||||
repair_failure_pending = [False]
|
||||
cleanup_attempts = []
|
||||
def ssh(cmd, input=None, **kw):
|
||||
repair_stage = None
|
||||
if repairing[0] and repair_failure_pending[0]:
|
||||
if '/meta.json.tmp' in cmd and cmd.startswith('cat >'):
|
||||
repair_stage = 'meta'
|
||||
elif '/update-recovery.json.tmp' in cmd and cmd.startswith('cat >'):
|
||||
repair_stage = 'checkpoint'
|
||||
elif cmd.startswith('rm -rf ') and '.frame-update-backup' in cmd:
|
||||
repair_stage = 'cleanup'
|
||||
if repair_failure == 'cleanup-partial' and repair_stage == 'cleanup':
|
||||
(d / '.frame-update-backup/ready').unlink()
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('partial repair cleanup')
|
||||
if repair_failure == repair_stage:
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('transient repair ' + repair_stage)
|
||||
if failure == 'cleanup' and cmd == 'rm -rf Applications/Android/org.test.app/.frame-update-backup' and not cleanup_attempts:
|
||||
cleanup_attempts.append(cmd)
|
||||
raise android.FrameError('backup cleanup unavailable')
|
||||
if repairing[0] and cmd == 'python3 -':
|
||||
return json.dumps({'icon_png': '', 'artwork': {}})
|
||||
if failure == 'rollback' and 'frame-update-backup' in cmd and ('cp -' in cmd or 'mv ' in cmd) and writes and not repairing[0]:
|
||||
raise android.FrameError('restore connection lost')
|
||||
if failure == 'meta' and '/meta.json.tmp' in cmd and not writes:
|
||||
writes.append('meta failed')
|
||||
raise android.FrameError('metadata unavailable')
|
||||
if isinstance(input, str):
|
||||
input = input.encode()
|
||||
p = subprocess.run(['sh', '-c', cmd], input=input, cwd=root,
|
||||
env={**os.environ, 'HOME': str(root)}, capture_output=True)
|
||||
if p.returncode:
|
||||
raise android.FrameError(p.stderr.decode())
|
||||
if repair_stage and repair_failure == repair_stage + '-after':
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('lost repair ' + repair_stage + ' response')
|
||||
if failure == 'meta-after' and '/meta.json.tmp' in cmd and not writes:
|
||||
writes.append('meta response lost')
|
||||
raise android.FrameError('metadata response lost')
|
||||
return p.stdout.decode()
|
||||
def copy(src, dest, **kw):
|
||||
target = root / dest
|
||||
shutil.copyfile(src, target)
|
||||
if kw.get('executable'):
|
||||
target.chmod(0o755)
|
||||
def shortcut_tool(*args, **kw):
|
||||
if args[0] == 'add':
|
||||
return '43'
|
||||
if args[0] == 'remove':
|
||||
if not repairing[0]:
|
||||
raise android.FrameError('shortcut cleanup unavailable')
|
||||
return '{}'
|
||||
raise AssertionError(args)
|
||||
def library(*a, **kw):
|
||||
if repairing[0]:
|
||||
self.assertEqual(a[1], 'Old')
|
||||
self.assertTrue(kw['vr'])
|
||||
self.assertEqual(kw['fill_only'], not (d / 'update-recovery.json').exists())
|
||||
(root / 'steam-state').write_text('old library settings')
|
||||
if repair_failure_pending[0] and repair_failure == 'library':
|
||||
repair_failure_pending[0] = False
|
||||
raise android.FrameError('transient repair library failure')
|
||||
return {'artwork': {}, 'warnings': []}
|
||||
# A partial library mutation is externally observable even when configure fails.
|
||||
(root / 'steam-state').write_text('new library settings')
|
||||
if failure in ('library', 'rollback', 'orphan'):
|
||||
writes.append('library failed')
|
||||
raise android.FrameError('Steam unavailable after partial configure')
|
||||
return {'artwork': {}, 'warnings': []}
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_copy', side_effect=copy), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value=set() if failure == 'orphan' else {42}), \
|
||||
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool), \
|
||||
mock.patch.object(android, 'apply_library', side_effect=library):
|
||||
with self.assertRaises(android.FrameError) as error:
|
||||
android._install(str(apk), info, info['package'], True, None, None)
|
||||
if failure != 'rollback':
|
||||
for name, data in files.items():
|
||||
self.assertEqual((d / name).read_bytes(), data, name)
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
self.assertTrue((d / 'launch.sh').stat().st_mode & 0o100)
|
||||
self.assertFalse((d / 'app.apk.part').exists())
|
||||
self.assertFalse((d / 'meta.json.tmp').exists())
|
||||
self.assertEqual((d / 'user-save').read_bytes(), b'precious data')
|
||||
self.assertEqual(save.read_bytes(), b'Android saved data')
|
||||
# Steam's API cannot guarantee a full rollback. Never hide that residual state.
|
||||
self.assertIn('repair', str(error.exception).lower())
|
||||
record = json.loads((d / 'update-recovery.json').read_text())
|
||||
self.assertIn('error', record)
|
||||
self.assertIn('repair', record)
|
||||
if failure == 'rollback':
|
||||
self.assertIn('restore connection lost', str(error.exception))
|
||||
self.assertTrue((d / '.frame-update-backup').is_dir())
|
||||
if failure == 'orphan':
|
||||
self.assertEqual(record['orphan_shortcut'], 43)
|
||||
self.assertIn('shortcut cleanup unavailable', str(error.exception))
|
||||
snapshot = {str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copying')):
|
||||
with self.assertRaises(android.FrameError):
|
||||
android._install(str(apk), info, info['package'], True, None, None)
|
||||
self.assertEqual({str(p.relative_to(d)): p.read_bytes() for p in d.rglob('*') if p.is_file()}, snapshot,
|
||||
'Retry must preserve all prior recovery evidence byte for byte')
|
||||
repairing[0] = True
|
||||
with mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
|
||||
mock.patch.object(android, 'shortcut_tool', side_effect=shortcut_tool) as api, \
|
||||
mock.patch.object(android, 'apply_library', side_effect=library):
|
||||
if repair_failure:
|
||||
repair_failure_pending[0] = True
|
||||
with self.assertRaises(android.FrameError):
|
||||
android.refresh_art(info['package'], fill_only=True)
|
||||
# A later failure must retain either the ready snapshot or a
|
||||
# durable terminal checkpoint, never an unconfirmed dead end.
|
||||
if (d / 'update-recovery.json').exists():
|
||||
retry_record = json.loads((d / 'update-recovery.json').read_text())
|
||||
for key in ('error', 'rollback_error', 'orphan_shortcut', 'cleanup_error'):
|
||||
self.assertEqual(retry_record[key], record[key], key)
|
||||
self.assertTrue((d / '.frame-update-backup/ready').exists() or
|
||||
retry_record.get('repair_completed') is True)
|
||||
else:
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
self.assertEqual((d / 'app.apk').read_bytes(), files['app.apk'])
|
||||
self.assertEqual((d / 'launch.sh').read_bytes(), files['launch.sh'])
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
coherent = json.loads((d / 'meta.json').read_text())
|
||||
self.assertEqual(coherent['version'], '1')
|
||||
self.assertFalse(coherent['flatscreen'])
|
||||
repaired = android.refresh_art(info['package'], fill_only=True)
|
||||
if failure == 'orphan':
|
||||
api.assert_called_once_with('remove', '43')
|
||||
self.assertEqual(repaired['version'], '1')
|
||||
self.assertFalse(repaired['flatscreen'])
|
||||
self.assertEqual((root / 'steam-state').read_text(), 'old library settings')
|
||||
for name in ('app.apk', 'launch.sh', 'instance.id', 'shortcut.id', 'user-save'):
|
||||
self.assertEqual((d / name).read_bytes(), files[name], name)
|
||||
self.assertFalse((d / 'update-recovery.json').exists())
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
self.assertFalse((d / 'lepton-show-flatscreen').exists())
|
||||
self.assertEqual(save.read_bytes(), b'Android saved data')
|
||||
return record
|
||||
|
||||
def test_late_library_failure_restores_executable_metadata_and_preserves_data(self):
|
||||
self.exercise('library')
|
||||
|
||||
def test_meta_write_failure_restores_executable_metadata_and_preserves_data(self):
|
||||
self.exercise('meta')
|
||||
|
||||
def test_missing_snapshot_cannot_claim_an_unconfirmed_rollback_is_repaired(self):
|
||||
def ssh(cmd, **kw):
|
||||
if cmd.startswith('test -f'):
|
||||
return 'yes'
|
||||
if cmd.startswith('cat ') and cmd.endswith('update-recovery.json'):
|
||||
return json.dumps({'files_restored': False, 'library_may_have_changed': True})
|
||||
if cmd == 'python3 -':
|
||||
return json.dumps({'icon_png': '', 'artwork': {}})
|
||||
return ''
|
||||
meta = {'package': 'org.test.app', 'label': 'Old', 'shortcut': 42, 'flatscreen': False}
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android, '_meta_or_fail', return_value=meta), \
|
||||
mock.patch.object(android, '_shortcut_ids', return_value={42}), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', return_value=({}, [])), \
|
||||
mock.patch.object(android, '_write_meta'), \
|
||||
mock.patch.object(android, 'apply_library', return_value={}) as library:
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
library.assert_not_called()
|
||||
|
||||
def test_retry_preserves_absent_snapshot_uncertainty_and_repair_still_refuses(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
record = {'files_restored': False, 'files_unchanged': False, 'error': 'original lost rollback',
|
||||
'library_may_have_changed': True, 'orphan_shortcut': 43}
|
||||
(d / 'update-recovery.json').write_text(json.dumps(record))
|
||||
evidence = (d / 'update-recovery.json').read_bytes()
|
||||
def ssh(cmd, input=None, **kw):
|
||||
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('must refuse before copy')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), evidence)
|
||||
|
||||
def test_recovery_appearing_after_preflight_is_not_owned_by_the_new_attempt(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
d.mkdir(parents=True)
|
||||
meta = {'package': 'org.test.app', 'instance': 2800000001, 'shortcut': 42, 'label': 'Old'}
|
||||
(d / 'meta.json').write_text(json.dumps(meta))
|
||||
(d / 'app.apk').write_bytes(b'old APK')
|
||||
record = b'{"orphan_shortcut":43,"files_restored":false,"error":"prior failure"}'
|
||||
def prepare(*args):
|
||||
(d / 'update-recovery.json').write_bytes(record)
|
||||
(d / 'app.apk.part').write_bytes(b'prior partial evidence')
|
||||
return {}, []
|
||||
def ssh(cmd, input=None, **kw):
|
||||
if isinstance(input, str):
|
||||
input = input.encode()
|
||||
result = subprocess.run(['sh', '-c', cmd], input=input, cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=prepare), \
|
||||
mock.patch.object(android, '_copy', side_effect=AssertionError('pending recovery must refuse before copy')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
self.assertEqual((d / 'update-recovery.json').read_bytes(), record)
|
||||
self.assertEqual((d / 'app.apk.part').read_bytes(), b'prior partial evidence')
|
||||
self.assertEqual((d / 'app.apk').read_bytes(), b'old APK')
|
||||
self.assertFalse((d / '.frame-update-backup').exists())
|
||||
|
||||
def test_snapshot_without_record_cannot_be_repaired_or_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
d = root / 'Applications/Android/org.test.app'
|
||||
backup = d / '.frame-update-backup'
|
||||
backup.mkdir(parents=True)
|
||||
(backup / 'ready').touch()
|
||||
(backup / 'app.apk').write_bytes(b'old APK evidence')
|
||||
def ssh(cmd, input=None, **kw):
|
||||
result = subprocess.run(['sh', '-c', cmd], cwd=root, capture_output=True)
|
||||
if result.returncode:
|
||||
raise android.FrameError(result.stderr.decode())
|
||||
return result.stdout.decode()
|
||||
with mock.patch.object(android, 'ssh', side_effect=ssh), \
|
||||
mock.patch.object(android.frame_artwork, 'prepare', side_effect=AssertionError('must refuse before artwork')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'Pending'):
|
||||
android._install('unused.apk', {'label': 'New'}, 'org.test.app', True, None, None)
|
||||
with self.assertRaisesRegex(android.FrameError, 'unconfirmed'):
|
||||
android.refresh_art('org.test.app')
|
||||
self.assertFalse((d / 'update-recovery.json').exists())
|
||||
self.assertEqual((backup / 'app.apk').read_bytes(), b'old APK evidence')
|
||||
|
||||
def test_meta_write_response_loss_restores_old_metadata_too(self):
|
||||
self.exercise('meta-after')
|
||||
|
||||
def test_new_shortcut_cleanup_failure_is_recorded_and_repaired(self):
|
||||
self.exercise('orphan')
|
||||
|
||||
def test_backup_cleanup_failure_rolls_back_and_has_a_repair_path(self):
|
||||
self.exercise('cleanup')
|
||||
|
||||
def test_restore_failure_reports_and_keeps_recovery_evidence(self):
|
||||
self.exercise('rollback')
|
||||
|
||||
def test_repair_retains_recovery_until_all_stages_and_cleanup_succeed(self):
|
||||
for stage in ('library', 'meta', 'meta-after', 'checkpoint',
|
||||
'checkpoint-after', 'cleanup', 'cleanup-partial', 'cleanup-after'):
|
||||
with self.subTest(stage=stage):
|
||||
self.exercise('rollback', repair_failure=stage)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Standalone page regressions with production JavaScript and no live services."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises the standalone page scripts')
|
||||
class StandaloneReview(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', str(root / 'tests/standalone_review.cjs')],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""The store's Frame categories (ui/apk_sources/_categories.py): which apps go where, which are
|
||||
phone-only and hidden, and how the browse, search and library artwork use them. Offline."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
from apk_sources import SourceError, _categories, _images, fdroid, search # noqa: E402
|
||||
import test_fdroid_sources as fixtures # noqa: E402 (a module, so its tests don't run twice)
|
||||
FIXTURES, PIN, URL = fixtures.FIXTURES, fixtures.PIN, fixtures.URL
|
||||
|
||||
|
||||
def kind(**entry):
|
||||
return _categories.classify(dict({'name': 'App', 'summary': ''}, **entry))
|
||||
|
||||
|
||||
class Classify(unittest.TestCase):
|
||||
def test_vr_first_whatever_else_it_is(self):
|
||||
self.assertEqual(kind(vr=True, categories=['Phone & SMS']), {'category': 'vr', 'phone_only': False})
|
||||
|
||||
def test_fine_categories_decide(self):
|
||||
self.assertEqual(kind(categories=['Games', 'Puzzle Game'])['category'], 'games')
|
||||
self.assertEqual(kind(categories=['Games', 'Emulator'])['category'], 'emulators')
|
||||
# A chat app is in Multimedia too; the finer Social Network wins.
|
||||
self.assertEqual(kind(categories=['Multimedia', 'Internet', 'Social Network'])['category'], 'web')
|
||||
self.assertEqual(kind(categories=['Reading', 'Ebook Reader'])['category'], 'read')
|
||||
self.assertEqual(kind(categories=['Multimedia', 'Podcast'])['category'], 'watch')
|
||||
|
||||
def test_broad_categories_alone(self):
|
||||
self.assertEqual(kind(categories=['Multimedia']), {'category': 'watch', 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Writing']), {'category': 'work', 'phone_only': False})
|
||||
|
||||
def test_phone_only(self):
|
||||
for categories in (['System', 'Launcher'], ['Internet', 'VPN & Proxy'], ['Theming', 'Wallpaper'],
|
||||
['Sports & Health'], ['Connectivity'], ['Time', 'Alarm Clock'], ['Habit Tracker']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertTrue(kind(categories=categories)['phone_only'])
|
||||
self.assertTrue(kind(name='[ROOT] Night Light', categories=['Development'])['phone_only'])
|
||||
self.assertTrue(kind(name='5G Proxy Client', summary='Routes traffic')['phone_only'])
|
||||
|
||||
def test_games_and_media_keep_phone_words(self):
|
||||
# 'Battery' in a game's name or a widget in a music player doesn't hide a game or a player.
|
||||
self.assertFalse(kind(name='Widget Wars', categories=['Strategy Game'])['phone_only'])
|
||||
self.assertFalse(kind(name='Music', summary='with a home screen widget', categories=['Local Media Player'])['phone_only'])
|
||||
|
||||
def test_emulators_by_name_without_categories(self):
|
||||
self.assertEqual(kind(name='RetroArch', summary='Frontend for emulators')['category'], 'emulators')
|
||||
|
||||
def test_unknown_is_neither_hidden_nor_guessed(self):
|
||||
self.assertEqual(kind(name='Thing'), {'category': None, 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Something New']), {'category': None, 'phone_only': False})
|
||||
|
||||
def test_matches(self):
|
||||
phone = {'category': 'tools', 'phone_only': True}
|
||||
self.assertFalse(_categories.matches(phone))
|
||||
self.assertTrue(_categories.matches(phone, phone=True))
|
||||
self.assertTrue(_categories.matches({'category': 'games', 'phone_only': False}, 'games'))
|
||||
self.assertFalse(_categories.matches({'category': 'watch', 'phone_only': False}, 'games'))
|
||||
|
||||
def test_every_store_category_has_a_label(self):
|
||||
self.assertEqual({c for c, _ in _categories.CATEGORIES}, _categories.IDS)
|
||||
targets = set(_categories.FINE) | {c for c in _categories.BROAD.values() if c}
|
||||
self.assertLessEqual(targets, _categories.IDS)
|
||||
self.assertLessEqual(set(_categories.PRIORITY), _categories.IDS)
|
||||
|
||||
|
||||
class FDroidCategories(unittest.TestCase):
|
||||
setUp, fetch, add = fixtures.Repositories.setUp, fixtures.Repositories.fetch, fixtures.Repositories.add
|
||||
|
||||
def index(self, version, categories):
|
||||
raw = json.loads((FIXTURES / ('artwork-' + version + '.json')).read_text())
|
||||
if version == 'v2':
|
||||
raw['packages']['org.example.app']['metadata']['categories'] = categories
|
||||
else:
|
||||
raw['apps'][0]['categories'] = categories
|
||||
path = self.root / ('index-' + version + '.json')
|
||||
path.write_text(json.dumps(raw))
|
||||
if version == 'v1':
|
||||
fdroid._v1(path.read_bytes(), path)
|
||||
return path
|
||||
|
||||
def test_categories_survive_v1_and_v2(self):
|
||||
source = self.add()
|
||||
for version in ('v1', 'v2'):
|
||||
with self.subTest(version=version):
|
||||
apps = fdroid._reduce(self.index(version, ['Games', 'Puzzle Game', 7]), source)
|
||||
self.assertEqual(apps['org.example.app']['categories'], ['Games', 'Puzzle Game'])
|
||||
|
||||
def save(self, source, apps):
|
||||
fdroid._write(fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json'),
|
||||
{'version': fdroid.CACHE_VERSION, 'url': URL, 'fingerprint': PIN, 'apps': apps})
|
||||
|
||||
def app(self, pkg, name, updated, categories=(), banner=None):
|
||||
return {'source': 'x', 'id': pkg, 'package': pkg, 'name': name, 'summary': '', 'updated': updated,
|
||||
'categories': list(categories), 'images': {'icon': None, 'banner': banner, 'screenshots': []}}
|
||||
|
||||
def test_browse_shows_artwork_then_newest_and_filters_before_the_limit(self):
|
||||
source = self.add()
|
||||
self.save(source, {a['id']: a for a in (
|
||||
self.app('a.old', 'Aaa', '2020-01-01', ['Puzzle Game']),
|
||||
self.app('b.new', 'Bbb', '2026-01-01', ['Puzzle Game']),
|
||||
self.app('c.art', 'Ccc', '2019-01-01', ['Puzzle Game'], banner=URL + 'b.png'),
|
||||
self.app('d.sms', 'Ddd', '2026-02-01', ['Phone & SMS']))})
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=10)], ['c.art', 'd.sms', 'b.new', 'a.old'])
|
||||
keep = lambda e: _categories.matches(dict(e, **_categories.classify(e)))
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=1, keep=keep)], ['c.art'])
|
||||
self.assertEqual([a['id'] for a in fdroid.search(source, 'a', limit=10)], ['a.old', 'c.art']) # a query sorts by name
|
||||
|
||||
def test_lookup_reads_the_saved_index_only(self):
|
||||
source = self.add()
|
||||
self.assertEqual(fdroid.lookup(dict(source, id='never-loaded'), ['org.example.app']), {})
|
||||
self.save(source, {'a.b': self.app('a.b', 'A', None)})
|
||||
before = self.fetch_mock.call_count
|
||||
self.assertEqual(list(fdroid.lookup(source, ['a.b', 'missing'])), ['a.b'])
|
||||
self.assertEqual(self.fetch_mock.call_count, before)
|
||||
self.assertEqual(fdroid.lookup(dict(source, enabled=False), ['a.b']), {})
|
||||
|
||||
|
||||
def fake_source(entries, kind_='fake', lookup=None):
|
||||
mod = types.SimpleNamespace(KIND=kind_)
|
||||
mod.sources = lambda: [{'id': kind_, 'kind': kind_, 'name': kind_.title(), 'url': 'https://example.org/',
|
||||
'builtin': True, 'enabled': True, 'trust': 'community'}]
|
||||
mod.search = lambda source, query, limit=50: [dict(e) for e in entries][:limit]
|
||||
mod.details = lambda source, entry_id: next(dict(e) for e in entries if e['id'] == entry_id)
|
||||
if lookup is not None:
|
||||
mod.lookup = lookup
|
||||
return mod
|
||||
|
||||
|
||||
ENTRY = {'source': 'fake', 'package': None, 'summary': '', 'free': True, 'downloadable': True,
|
||||
'min_sdk': 24, 'abis': ['arm64-v8a'], 'vr': None}
|
||||
|
||||
|
||||
class Search(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
for state in (search._running, search._pending, search._status):
|
||||
state.clear()
|
||||
self.addCleanup(state.clear)
|
||||
|
||||
def run_search(self, entries, **kwargs):
|
||||
with patch.object(search, 'modules', return_value=([fake_source(entries)], [])):
|
||||
return search.search(**kwargs)
|
||||
|
||||
def test_category_and_phone_filters(self):
|
||||
entries = [dict(ENTRY, id='g', name='Game', categories=['Puzzle Game']),
|
||||
dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS']),
|
||||
dict(ENTRY, id='v', name='Video', categories=['Local Media Player'])]
|
||||
result = self.run_search(entries)
|
||||
self.assertEqual(sorted(a['name'] for a in result['apps']), ['Game', 'Video'])
|
||||
self.assertEqual({a['name']: a['category'] for a in result['apps']}, {'Game': 'games', 'Video': 'watch'})
|
||||
self.assertIn({'id': 'emulators', 'label': 'Emulators'}, result['categories'])
|
||||
self.assertEqual([a['name'] for a in self.run_search(entries, category='games')['apps']], ['Game'])
|
||||
self.assertEqual(len(self.run_search(entries, phone=True)['apps']), 3)
|
||||
with self.assertRaisesRegex(SourceError, 'Unknown category'):
|
||||
self.run_search(entries, category='phones')
|
||||
|
||||
def test_listing_offers_three_screenshots_details_all(self):
|
||||
shots = ['https://example.org/%d.png' % i for i in range(8)]
|
||||
entry = dict(ENTRY, id='g', name='Game', images={'icon': None, 'banner': None, 'screenshots': shots})
|
||||
listed = self.run_search([entry])['apps'][0]['offers'][0]
|
||||
self.assertEqual(len(listed['artwork']['screenshots']), 3)
|
||||
self.assertEqual(len(search.decorate(entry)['artwork']['screenshots']), 8)
|
||||
|
||||
def test_browse_asks_sources_for_more_than_a_search(self):
|
||||
seen = []
|
||||
mod = fake_source([])
|
||||
mod.search = lambda source, query, limit=50: seen.append((query, limit)) or []
|
||||
with patch.object(search, 'modules', return_value=([mod], [])):
|
||||
search.search('')
|
||||
search.search('chess')
|
||||
self.assertEqual(seen, [('', search.BROWSE_LIMIT), ('chess', 50)])
|
||||
|
||||
|
||||
class LibraryArtwork(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_by_package_from_saved_indexes_then_by_name_for_curated_apps(self):
|
||||
icon = {'images': {'icon': 'https://example.org/icon.png', 'banner': None, 'screenshots': []}}
|
||||
repo = fake_source([], 'fdroid', lookup=lambda source, packages: {
|
||||
p: dict(icon, name='x') for p in packages if p == 'org.antennapod'})
|
||||
github = fake_source([dict(icon, id='icosa/open-brush', name='Open Brush')], 'github')
|
||||
bare = fake_source([dict(icon, id='x', name='Elsewhere', package='org.elsewhere')], 'itch')
|
||||
apps = [{'package': 'org.antennapod', 'label': 'AntennaPod'}, {'package': 'com.Icosa.OpenBrush', 'label': 'Open Brush'},
|
||||
{'package': 'org.elsewhere', 'label': 'Elsewhere'}, {'package': 'org.none', 'label': 'None'}]
|
||||
with patch.object(search, 'modules', return_value=([repo, github, bare], [])):
|
||||
found = search.artwork_for(apps)
|
||||
self.assertEqual(sorted(found), ['com.Icosa.OpenBrush', 'org.antennapod'])
|
||||
self.assertTrue(found['org.antennapod']['icon'].startswith('/source-image/'))
|
||||
|
||||
def test_a_failing_source_is_skipped(self):
|
||||
def broken(source, packages):
|
||||
raise SourceError('no')
|
||||
with patch.object(search, 'modules', return_value=([fake_source([], 'fdroid', lookup=broken)], [])):
|
||||
self.assertEqual(search.artwork_for([{'package': 'a.b', 'label': 'A'}]), {})
|
||||
|
||||
|
||||
class ImageHandles(unittest.TestCase):
|
||||
def test_same_url_same_handle_without_scanning(self):
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
a = _images.register('https://example.org/a.png')
|
||||
self.assertEqual(_images.register('https://example.org/a.png'), a)
|
||||
self.assertNotEqual(_images.register('https://example.org/b.png'), a)
|
||||
|
||||
|
||||
|
||||
class Route(unittest.TestCase):
|
||||
def test_search_filters_are_checked(self):
|
||||
import server
|
||||
with patch.object(server.apk_search, 'search', return_value={'apps': []}) as run:
|
||||
server.source_search('q=&category=games&phone=1')
|
||||
self.assertEqual(run.call_args.kwargs['category'], 'games')
|
||||
self.assertIs(run.call_args.kwargs['phone'], True)
|
||||
server.source_search('q=chess')
|
||||
self.assertEqual((run.call_args.kwargs['category'], run.call_args.kwargs['phone']), (None, False))
|
||||
for bad in ('phone=maybe', 'category=' + 'x' * 41):
|
||||
with self.subTest(bad=bad), self.assertRaises(server.Failure):
|
||||
server.source_search(bad)
|
||||
|
||||
|
||||
|
||||
class ReviewCases(unittest.TestCase):
|
||||
"""Real index entries (October 2026) that the first rules got wrong."""
|
||||
def test_useful_apps_with_a_phone_side_stay(self):
|
||||
for categories, category in ((['Connectivity', 'Email', 'Internet', 'Writing'], 'web'), # Thunderbird
|
||||
(['Connectivity', 'Internet', 'Multimedia', 'Podcast'], 'watch'), # FocusPodcast
|
||||
(['Contact', 'Email'], 'web'),
|
||||
(['Cloud Storage & File Sync', 'File Manager', 'System'], 'tools')):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertEqual(kind(categories=categories), {'category': category, 'phone_only': False})
|
||||
|
||||
def test_phone_side_alone_or_with_a_broad_category_is_hidden(self):
|
||||
for categories in (['Internet', 'VPN & Proxy'], ['App Manager', 'Development', 'System'], ['Navigation']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertTrue(kind(categories=categories)['phone_only'])
|
||||
self.assertTrue(kind(categories=['Launcher', 'Games'])['phone_only']) # system ones always
|
||||
|
||||
def test_no_root_needed_is_not_a_root_tool(self):
|
||||
for summary in ('Linux on Android, no root required', 'Linux Without Root Access', 'SSH server without root',
|
||||
'Works on non-rooted phones', 'Rootless and simple'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertFalse(kind(summary=summary, categories=['Development'])['phone_only'])
|
||||
self.assertTrue(kind(summary='Needs root to tune the kernel', categories=['Development'])['phone_only'])
|
||||
|
||||
def test_izzyondroid_names(self):
|
||||
self.assertEqual(kind(categories=['Office'])['category'], 'work')
|
||||
self.assertTrue(kind(categories=['Xposed'])['phone_only'])
|
||||
|
||||
|
||||
|
||||
class ReviewCasesTwo(unittest.TestCase):
|
||||
"""Round two of real entries (October 2026)."""
|
||||
def test_messengers_with_phone_or_push_stay(self):
|
||||
for categories in (['Internet', 'Messaging', 'Phone & SMS'], ['Internet', 'Messaging', 'Push'],
|
||||
['Connectivity', 'Messaging', 'Phone & SMS', 'Voice & Video Chat']):
|
||||
with self.subTest(categories=categories):
|
||||
self.assertEqual(kind(categories=categories), {'category': 'web', 'phone_only': False})
|
||||
|
||||
def test_broad_only_sources_can_escape_a_phone_side(self):
|
||||
self.assertEqual(kind(categories=['System', 'Development']), {'category': 'tools', 'phone_only': False})
|
||||
self.assertEqual(kind(categories=['Connectivity', 'Internet']), {'category': 'web', 'phone_only': False})
|
||||
self.assertTrue(kind(categories=['VPN & Proxy', 'Internet'])['phone_only']) # it has finer ones, none useful
|
||||
|
||||
def test_summary_features_dont_make_a_phone_app(self):
|
||||
self.assertFalse(kind(name='OSS Weather', summary='Forecasts with home screen widgets', categories=['Weather'])['phone_only'])
|
||||
self.assertFalse(kind(name='Digital Calculator', summary='Hardware keyboard support', categories=['Calculator', 'System'])['phone_only'])
|
||||
self.assertTrue(kind(name='Clock Widget', categories=['Clock'])['phone_only']) # but the name says what it is
|
||||
|
||||
def test_saying_root_isnt_needed(self):
|
||||
for summary in ('VNC server app that does not require root privileges', "Backups that don't need root access",
|
||||
"It doesn't need root", 'Linux without root', 'Works on non-rooted phones', 'Rootless'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertFalse(kind(summary=summary, categories=['Remote Access'])['phone_only'])
|
||||
for summary in ('Requires root', 'Needs root access to change the kernel', 'For rooted devices'):
|
||||
with self.subTest(summary=summary):
|
||||
self.assertTrue(kind(summary=summary, categories=['Remote Access'])['phone_only'])
|
||||
|
||||
def test_hardware_the_frame_lacks(self):
|
||||
self.assertTrue(kind(name='IR Blaster Remote', categories=['Multimedia', 'Remote Controller', 'System'])['phone_only'])
|
||||
self.assertTrue(kind(name='Wear Sync for Breezy', summary='Weather on your Wear OS watch', categories=['Connectivity', 'Weather'])['phone_only'])
|
||||
|
||||
|
||||
class SearchFindsPhoneApps(unittest.TestCase):
|
||||
setUp, run_search = Search.setUp, Search.run_search
|
||||
|
||||
def test_a_search_finds_what_browsing_hides(self):
|
||||
entries = [dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS'])]
|
||||
self.assertEqual(self.run_search(entries)['apps'], [])
|
||||
found = self.run_search(entries, query='texts')['apps']
|
||||
self.assertEqual([(a['name'], a['offers'][0]['phone_only']) for a in found], [('Texts', True)])
|
||||
|
||||
|
||||
|
||||
class ArtworkLookups(unittest.TestCase):
|
||||
"""A page of cards asks for dozens of images at once; they mustn't fail for want of a lookup slot."""
|
||||
def setUp(self):
|
||||
_images._dns.clear()
|
||||
self.addCleanup(_images._dns.clear)
|
||||
|
||||
def test_public_answers_are_kept_private_ones_are_not(self):
|
||||
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
|
||||
with patch.object(_images.socket, 'getaddrinfo', return_value=public) as lookup:
|
||||
for _ in range(3):
|
||||
self.assertEqual(_images._resolve('example.org', 443, 5), public)
|
||||
self.assertEqual(lookup.call_count, 1)
|
||||
private = [(2, 1, 6, '', ('10.0.0.5', 443))]
|
||||
with patch.object(_images.socket, 'getaddrinfo', return_value=private) as lookup:
|
||||
_images._resolve('inside.example', 443, 5)
|
||||
_images._resolve('inside.example', 443, 5)
|
||||
self.assertEqual(lookup.call_count, 2)
|
||||
|
||||
def test_waiting_for_a_slot_counts_against_the_budget(self):
|
||||
import threading, time
|
||||
gate = threading.Event()
|
||||
for _ in range(4): # every slot busy for the whole test
|
||||
_images._resolvers.acquire()
|
||||
threading.Timer(0.06, lambda: [_images._resolvers.release() for _ in range(4)]).start()
|
||||
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(2) and []):
|
||||
start = time.monotonic()
|
||||
with self.assertRaises(SourceError):
|
||||
_images._resolve('slow.example', 443, 0.1)
|
||||
self.assertLess(time.monotonic() - start, 0.16)
|
||||
gate.set()
|
||||
|
||||
def test_many_at_once_wait_for_a_slot(self):
|
||||
import threading, time
|
||||
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
|
||||
def slow(*a, **k):
|
||||
time.sleep(0.05)
|
||||
return public
|
||||
errors = []
|
||||
def one(i):
|
||||
try:
|
||||
_images._resolve('host%d.example' % i, 443, 5)
|
||||
except SourceError as e:
|
||||
errors.append(e)
|
||||
with patch.object(_images.socket, 'getaddrinfo', side_effect=slow):
|
||||
threads = [threading.Thread(target=one, args=(i,)) for i in range(12)]
|
||||
[t.start() for t in threads]
|
||||
[t.join() for t in threads]
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -20,9 +21,15 @@ 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."""
|
||||
|
||||
@@ -92,6 +99,47 @@ 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)
|
||||
@@ -111,11 +159,79 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
def test_frame_once_per_headset_with_saved_count(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
|
||||
props = [e["properties"] for e in self.queued()]
|
||||
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
|
||||
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
|
||||
|
||||
def test_frame_many_headsets_are_not_sent_twice(self):
|
||||
ids = ["%016x" % n for n in range(25)]
|
||||
for _ in range(2):
|
||||
for h in ids:
|
||||
tm.frame_seen("20260922.1", "3.8", h, 25)
|
||||
self.assertEqual(len(self.queued()), 25)
|
||||
|
||||
def test_frame_headset_id_must_be_the_hash(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
|
||||
p = self.queued()[0]["properties"]
|
||||
self.assertNotIn("headset_id", p)
|
||||
self.assertNotIn("headsets_saved", p)
|
||||
|
||||
|
||||
class HeadsetId(unittest.TestCase):
|
||||
def test_hash_of_machine_id_and_none_without_one(self):
|
||||
import hashlib
|
||||
import frame_status
|
||||
with mock.patch.object(frame_status, "read", return_value="abc123"):
|
||||
self.assertEqual(frame_status.headset_id(),
|
||||
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
|
||||
with mock.patch.object(frame_status, "read", return_value=None):
|
||||
self.assertIsNone(frame_status.headset_id())
|
||||
|
||||
|
||||
class Sending(Base):
|
||||
def serve(self, status=200):
|
||||
@@ -150,6 +266,39 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
@@ -180,6 +329,107 @@ 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):
|
||||
@@ -205,6 +455,278 @@ 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."""
|
||||
|
||||
@@ -224,6 +746,12 @@ 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)
|
||||
|
||||
+244
-5
@@ -2,9 +2,15 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -51,12 +57,9 @@ 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)):
|
||||
t.xprop_root = lambda d, n, v=values: v
|
||||
self.assertEqual(t.focus_display(), want)
|
||||
self.assertEqual(self.t.focus_display({"GAMESCOPE_FOCUS_DISPLAY": values}), want)
|
||||
self.assertIsNone(self.t.focus_display({}))
|
||||
|
||||
def test_ascii_table_covers_printable_characters(self):
|
||||
for code in range(0x20, 0x7F):
|
||||
@@ -259,6 +262,236 @@ 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)."""
|
||||
@@ -268,6 +501,12 @@ 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):
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Production client race/error regressions, without any headset or browser writes."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page functions')
|
||||
class UIReview(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/ui_review.cjs')], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Production navigation and app actions, without a browser or headset."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises shared page behavior')
|
||||
class Workspace(unittest.TestCase):
|
||||
def test_behavior(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(['node', '--test', str(root / 'tests/ui_workspace.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
+22
-11
@@ -386,17 +386,28 @@ class ServerJobs(unittest.TestCase):
|
||||
def test_dead_servers_leftovers_swept(self):
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
# 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)
|
||||
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)
|
||||
|
||||
def test_temp_dir_failure_ends_the_job(self):
|
||||
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), vm = require('node:vm');
|
||||
const {workspaceFixture, html} = require('./workspace_fixture.cjs');
|
||||
const store = fs.readFileSync(require.resolve('../ui/store.html'),'utf8');
|
||||
const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
|
||||
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
|
||||
function connectionFixture() {
|
||||
const f=workspaceFixture(), c=f.context;
|
||||
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
|
||||
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
c.window.connDevice='a';
|
||||
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
|
||||
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
|
||||
return f;
|
||||
}
|
||||
test('helper starts and status reads name the headset shown when requested',async()=>{
|
||||
const f=connectionFixture(),c=f.context,requests=[];
|
||||
c.fetch=async(path,opts)=>{requests.push({path,opts});return {ok:true,json:async()=>({state:'ready'})};};
|
||||
await f.run("api('/api/input?start=1')");
|
||||
await f.run("api('/api/touch?start=1')");
|
||||
c.window.connDevice='b';
|
||||
await f.run("api('/api/input')");
|
||||
assert.deepEqual(requests.map(r=>r.opts.headers['X-Frame-Device']),['a','a','b']);
|
||||
assert.ok(requests.every(r=>r.opts.method===undefined&&r.opts.body===undefined));
|
||||
delete c.window.connDevice; // A local iOS server has one fixed SSH route.
|
||||
await f.run("api('/api/touch?start=1')");
|
||||
assert.equal(requests[3].opts.headers['X-Frame-Device'],undefined);
|
||||
});
|
||||
test('a late input failure cannot replay A keystrokes on B',async()=>{
|
||||
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
|
||||
Object.assign(c,{pad:{state:'ready',queue:[],sending:false},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
|
||||
f.run(pick(html,'const isMove = e =>','function padKey('));f.run('padSend({key:"for A"})');
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.pad.state,'off');assert.equal(c.pad.queue.length,0);
|
||||
rejectA(Error('lost A'));await tick();c.pad.state='ready';f.run('padSend({key:"for B"})');await tick();
|
||||
assert.equal(writes.length,2);assert.equal(writes[1].device,'b');assert.equal(writes[1].events[0].key,'for B');
|
||||
});
|
||||
test('a device switch discards completed install results and the open detail',()=>{
|
||||
const f=connectionFixture(),c=f.context;
|
||||
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
|
||||
f.el('appDetail').open=true;
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
|
||||
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{checked:false,value:'',textContent:'',children:[]});return els.get(id);};let pending=[],sent;
|
||||
const c={$ ,api:(path,body)=>path==='/api/report/preview'?new Promise(resolve=>pending.push({body,resolve})):Promise.resolve({}),log(){},renderContact(){}};vm.createContext(c);
|
||||
vm.runInContext(pick(html,'const bug = {','function openBugReport()'),c);
|
||||
vm.runInContext(pick(html,'$("bugForm").onsubmit =','if (window.frameApp && window.frameApp.onReportProblem)'),c);
|
||||
$('bugForm').reportValidity=()=>true;$('bugDiag').checked=true;$('bugLogs').checked=true;
|
||||
const a=vm.runInContext('loadBugPreview()',c);$('bugLogs').checked=false;const b=vm.runInContext('loadBugPreview()',c);
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
|
||||
pending[1].resolve({text:'safe summary'});await b;pending[0].resolve({text:'SECRET SERVER LOG'});await a;
|
||||
c.api=(path,body)=>{if(path==='/api/report')sent=body;return Promise.resolve({id:'stub',message:'stub'});};
|
||||
await $('bugForm').onsubmit({preventDefault(){}});assert.equal(sent.diagnostics,'safe summary');
|
||||
$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/safe summary|SECRET/);
|
||||
$('bugDiag').checked=true;c.api=(path,body)=>new Promise(resolve=>pending.push({body,resolve}));
|
||||
const late=vm.runInContext('loadBugPreview()',c);$('bugDiag').checked=false;await vm.runInContext('loadBugPreview()',c);
|
||||
pending[2].resolve({text:'LATE SECRET'});await late;
|
||||
assert.doesNotMatch(vm.runInContext('bugReportText().body',c),/SECRET/);
|
||||
});
|
||||
test('clearing Steam search invalidates a pending query and pending country lookup',async()=>{
|
||||
const el={value:'Portal'},gm={country:'US',src:'store',store:[],storeQ:null,seq:0,shownBySource:{}};let resolve;
|
||||
const c={$:()=>el,gm,api:()=>new Promise(r=>resolve=r),renderChips(){},filterGames(){},encodeURIComponent};vm.createContext(c);
|
||||
vm.runInContext(pick(html,'async function searchStore() {','function setGameSource('),c);
|
||||
const a=vm.runInContext('searchStore()',c);el.value='';await vm.runInContext('searchStore()',c);resolve({results:[{name:'Portal'}]});await a;
|
||||
assert.equal(gm.storeQ,null);assert.equal(gm.store.length,0);
|
||||
});
|
||||
test('failed installed reads remain unknown or stale with retry, never successful empty',async()=>{
|
||||
const els=new Map(),$=id=>{if(!els.has(id))els.set(id,{textContent:'',innerHTML:'',setAttribute(){}});return els.get(id);};
|
||||
const st={installed:new Map(),needsData:new Set(),jobs:new Map(),view:'installed',q:'',loading:false,apps:[],page:0,installedState:'unknown',installedError:'',installedRequest:0};
|
||||
const c={$ ,st,api:async()=>{throw Error('offline');},toast(){},document:{querySelectorAll:()=>[]},PER_PAGE:8,catLabel:x=>x,esc:x=>x};vm.createContext(c);
|
||||
vm.runInContext(pick(store,'async function loadInstalled() {','async function search('),c);vm.runInContext(pick(store,'function list() {','function card('),c);vm.runInContext(pick(store,'function render(error) {','function turn('),c);
|
||||
await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.doesNotMatch($('content').innerHTML,/Nothing installed yet/);assert.match($('content').innerHTML,/data-retry-installed/);
|
||||
st.installed.set('app',{package:'app',label:'Cached App'});st.installedState='ready';
|
||||
c.card=()=>'<button>Cached App</button>';await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.match($('content').innerHTML,/Cached App/);assert.match($('content').innerHTML,/data-retry-installed/);
|
||||
c.api=async()=>({apps:[]});await vm.runInContext('loadInstalled()',c);vm.runInContext('render()',c);
|
||||
assert.match($('content').innerHTML,/Nothing installed yet/);assert.doesNotMatch($('content').innerHTML,/data-retry-installed/);
|
||||
});
|
||||
|
||||
test('capture control discards old releases, timers and readiness on a target switch',async()=>{
|
||||
const f=connectionFixture(),c=f.context,writes=[];let rejectA;
|
||||
Object.assign(c,{ctrl:{on:true,queue:[],sending:false,state:'ready',held:new Set(['left']),keys:new Set([30]),pointers:new Map(),g:null,rel:[1,2]},desk:{panels:[{window:4}],focus:'old',shown:'old',loaded:true},cancelAnimationFrame(){},ctrlShow(){},ctrlAim:e=>e,
|
||||
pad:{queue:[],state:'off'},padReset(){},fetch:(path,opts)=>{writes.push({device:opts.headers['X-Frame-Device'],events:JSON.parse(opts.body).events});return writes.length===1?new Promise((_,reject)=>rejectA=reject):Promise.resolve({ok:true,json:async()=>({state:'ready',sent:true})});}});
|
||||
f.run(pick(html,'function ctrlReset() {','// Pointer moves:'));
|
||||
f.run('ctrlSend([{key:30,down:false}])');
|
||||
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
|
||||
assert.equal(c.ctrl.on,false);assert.equal(c.ctrl.state,'off');assert.equal(c.ctrl.held.size,0);assert.equal(c.desk.panels.length,0);
|
||||
rejectA(Error('lost A'));await tick();
|
||||
assert.equal(c.ctrl.queue.length,0);assert.equal(c.ctrl.retryAt,0);assert.equal(writes.length,1);
|
||||
});
|
||||
|
||||
test('late installation completion cannot populate the new headset detail',async()=>{
|
||||
const f=workspaceFixture(),c=f.context,offer={source:'catalog',id:'one',name:'One',package:'app.one',downloadable:true};let finish,progress,refreshes=0;
|
||||
Object.assign(c,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',loadAndroid:()=>refreshes++,
|
||||
runJob:async(_label,_key,start,update)=>{progress=update;await start();return new Promise(resolve=>finish=resolve);},api:async()=>({job:'a'})});
|
||||
f.run(pick(html,'function updateInstall() {','async function openStoreApp('));f.run(pick(html,'async function installStoreApp() {','async function changeSource('));
|
||||
const pending=f.run('installStoreApp()');await tick();
|
||||
c.devGen++;c.sourceState.jobs.clear();f.el('detailInstall').textContent='New headset';
|
||||
progress({stage:'Installing',percent:90});finish({package:'app.one',game_data:true});await pending;
|
||||
assert.equal(c.sourceState.jobs.size,0);assert.equal(f.el('detailInstall').textContent,'New headset');assert.equal(refreshes,0);
|
||||
});
|
||||
|
||||
test('late input startup and touch poll responses cannot re-enable the old target',async()=>{
|
||||
const f=workspaceFixture(),c=f.context;let finish;
|
||||
Object.assign(c,{pad:{state:'off'},clientPreferences:{set(){}},padShow:s=>{c.pad.state=s.state;},api:()=>new Promise(resolve=>finish=resolve)});
|
||||
f.run(pick(html,'async function padStart() {','$("padOn").onclick'));
|
||||
const start=f.run('padStart()');c.devGen++;c.pad.state='off';finish({state:'ready'});await start;assert.equal(c.pad.state,'off');
|
||||
Object.assign(c,{ctrl:{on:true,state:'off'},ctrlShow(){},ctrlFlush(){throw Error('Must not flush stale input');}});
|
||||
f.run(pick(html,'async function ctrlPoll(', '$("viewer").tabIndex'));
|
||||
const poll=f.run('ctrlPoll(0)');c.devGen++;c.ctrl.on=false;finish({state:'ready'});await poll;assert.equal(c.ctrl.state,'off');
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
|
||||
const deferred=()=>{let resolve,reject;const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject};};
|
||||
function installFixture() {
|
||||
const f=workspaceFixture();
|
||||
Object.assign(f.context,{wi:{queue:[]},wiNext(){f.calls.push(['review']);},sendFiles:files=>f.calls.push(['files',files])});
|
||||
const start=html.indexOf('function openInstallChooser()'),end=html.indexOf('function renderHome(',start);
|
||||
f.run(html.slice(start,end));return f;
|
||||
}
|
||||
test('Add / Install retains cancellation and hands chosen files to the existing transfer flow',()=>{
|
||||
const f=installFixture(),input=f.el('installFileInput');let clicks=0;
|
||||
input.click=()=>clicks++;f.run('openInstallChooser()');f.el('installChooseFile').onclick();assert.equal(clicks,1);
|
||||
input.files=[];input.onchange();assert.equal(f.el('installChooser').open,true);assert.equal(f.calls.length,0);
|
||||
const files=[{name:'game.zip'},{name:'player.apk'}];input.files=files;input.value='chosen';input.onchange();
|
||||
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.location.hash,'#transfer');assert.equal(input.value,'');
|
||||
assert.deepEqual(Array.from(f.calls[0][1]),files);
|
||||
});
|
||||
test('chooser destinations reapply explicit views when their hash is already active',()=>{
|
||||
const cases=[['#appStore',"selectApps('installed')",f=>assert.equal(f.el('appStore').hidden,false)],
|
||||
['#linuxApps',"selectApps('browse')",f=>assert.equal(f.el('appsLinux').hidden,false)],
|
||||
['#getgames',"selectLibrary('all','installed')",f=>assert.equal(f.context.gm.src,'owned')],
|
||||
['#steamStore',"selectLibrary('all','owned')",f=>assert.equal(f.context.gm.src,'store')]];
|
||||
for(const [hash,change,verify] of cases){
|
||||
const f=installFixture();f.navigate(hash);f.run(change);f.run('openInstallChooser()');let prevented=false;
|
||||
f.el('installChooser').onclick({target:{closest:()=>({getAttribute:()=>hash})},preventDefault(){prevented=true;}});
|
||||
assert.equal(prevented,true,hash);assert.equal(f.el('installChooser').open,false);verify(f);
|
||||
}
|
||||
});
|
||||
test('download links accept supported direct and manifest forms and reject malformed handoffs',()=>{
|
||||
const f=installFixture();
|
||||
for(const [raw,expected] of [
|
||||
[' https://example.com/game.zip ',{kind:'url',target:'https://example.com/game.zip'}],
|
||||
['frame-control://install?url=https%3A%2F%2Fexample.com%2Fapp.apk',{kind:'url',target:'https://example.com/app.apk'}],
|
||||
['frame-control://install?manifest=https%3A%2F%2Fexample.com%2Fgame.json',{kind:'manifest',target:'https://example.com/game.json'}]
|
||||
]){f.context.raw=raw;assert.deepEqual(JSON.parse(JSON.stringify(f.run('installLinkRequest(raw)'))),expected);}
|
||||
for(const raw of ['file:///tmp/app.apk','javascript:alert(1)','https://user:secret@example.com/a.apk',
|
||||
'frame-control://other?url=https://example.com/a.apk','frame-control://install/path?url=https://example.com/a.apk',
|
||||
'frame-control://install?url=https://example.com/a.apk&manifest=https://example.com/a.json',
|
||||
'frame-control://install?url=https://example.com/a.apk&url=https://example.com/b.apk',
|
||||
'frame-control://install?manifest=garbage','not a URL','https://example.com/'+ 'x'.repeat(2050)]){
|
||||
f.context.raw=raw;assert.throws(()=>f.run('installLinkRequest(raw)'));
|
||||
}
|
||||
});
|
||||
test('link form queues a review only, with inline errors and a bounded waiting queue',()=>{
|
||||
const f=installFixture(),submit=()=>f.el('installLinkForm').onsubmit({preventDefault(){}});
|
||||
f.run('openInstallChooser()');f.el('installUrl').value='invalid';submit();
|
||||
assert.equal(f.el('installChooser').open,true);assert.equal(f.el('installLinkError').hidden,false);assert.equal(f.context.wi.queue.length,0);assert.equal(f.calls.length,0);
|
||||
f.el('installUrl').value='https://example.com/game.zip';submit();
|
||||
assert.equal(f.el('installChooser').open,false);assert.equal(f.context.wi.queue.length,1);assert.deepEqual(f.calls,[['review']]);
|
||||
f.context.wi.queue=Array(5).fill({kind:'url',target:'https://example.com/game.zip'});f.run('openInstallChooser()');submit();
|
||||
assert.equal(f.el('installChooser').open,true);assert.equal(f.context.wi.queue.length,5);assert.match(f.el('installLinkError').textContent,/Finish or close/);
|
||||
});
|
||||
test('link review checks metadata but starts no download before the existing confirmation',async()=>{
|
||||
const f=installFixture(),writes=[];
|
||||
Object.assign(f.context,{log(){},gb:n=>String(n),api:async(path,body)=>{writes.push({path,body});return {id:'plan-1',name:'Game',host:'example.com',linkHost:'example.com',file:'game.zip',kindLabel:'PC game',size:100};}});
|
||||
f.el('wiGo').focus=()=>{};f.el('wiDlg').addEventListener=()=>{};
|
||||
const start=html.indexOf('const wi ='),end=html.indexOf('async function wiPoll(',start);f.run(html.slice(start,end));
|
||||
f.run("wi.queue.push({kind:'url',target:'https://example.com/game.zip'})");await f.run('wiNext()');
|
||||
assert.equal(writes.length,1);assert.equal(writes[0].path,'/api/webinstall/check');assert.equal(f.el('wiDlg').open,true);assert.equal(f.el('wiGo').disabled,false);
|
||||
const cancel=html.indexOf('$("wiCancel").onclick = async () =>');f.run(html.slice(cancel,html.indexOf('// Escape',cancel)));
|
||||
await f.el('wiCancel').onclick();assert.equal(f.el('wiDlg').open,false);assert.equal(writes.length,1);
|
||||
});
|
||||
test('catalog installs retain progress, failure retry and installed-app handoff',async()=>{
|
||||
const f=workspaceFixture(),offer={source:'fdroid',id:'org.player',package:'org.player',name:'Player',downloadable:true,version_code:42},writes=[];
|
||||
let success=false,refreshes=0,opened;
|
||||
Object.assign(f.context,{sourceState:{jobs:new Map()},currentOffer:()=>offer,sourcePage:()=>'',openInstalledApp:pkg=>opened=pkg,
|
||||
api:async(path,body)=>{writes.push({path,body});return {job:'job-1'};},loadAndroid:()=>refreshes++,
|
||||
runJob:async(label,key,start,progress)=>{await start();progress({stage:'Installing',percent:75});assert.equal(f.el('detailProgress').value,75);return success?{package:'org.player'}:undefined;}});
|
||||
const start=html.indexOf('function updateInstall()'),end=html.indexOf('async function openStoreApp(',start);f.run(html.slice(start,end));
|
||||
const install=html.indexOf('async function installStoreApp()');f.run(html.slice(install,html.indexOf('async function changeSource(',install)));
|
||||
await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Try again');assert.match(f.el('detailInstallNote').textContent,/couldn't finish/);
|
||||
success=true;await f.run('installStoreApp()');assert.equal(f.el('detailInstall').textContent,'Open in Steam');assert.equal(refreshes,1);
|
||||
assert.equal(writes.length,2);assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/sources/install',body:{source:'fdroid',id:'org.player',version_code:42}});
|
||||
f.context.sourceState.jobs.clear();f.context.androidApps=[{package:'org.player'}];f.el('appDetail').showModal();f.run('updateInstall()');
|
||||
assert.equal(f.el('detailInstall').textContent,'View installed app');await f.run('installStoreApp()');assert.equal(opened,'org.player');assert.equal(writes.length,2);assert.equal(f.el('appDetail').open,false);
|
||||
});
|
||||
test('Steam actions distinguish owned installation, installed play and non-owned store handoff',()=>{
|
||||
const f=workspaceFixture();
|
||||
f.context.gm.byId=new Map([[1,{installed:false}],[2,{installed:true}]]);
|
||||
const start=html.indexOf('function gameButtons(');f.run(html.slice(start,html.indexOf('function moreGames(',start)));
|
||||
assert.match(f.run("gameButtons(1,'Owned',false)"),/data-gm="install"/);
|
||||
assert.match(f.run("gameButtons(2,'Installed',false)"),/data-launch="2"/);
|
||||
for(const free of [false,true]){const output=f.run(`gameButtons(3,'Store',${free})`);assert.match(output,free?/Get on Steam/:/Buy on Steam/);assert.doesNotMatch(output,/data-gm="install"/);}
|
||||
f.context.gm.dl={appid:1,percent:30};assert.match(f.run("gameButtons(1,'Owned',false)"),/Downloading 30%/);
|
||||
});
|
||||
test('Apps Games Tools Settings expose installed, browse, ownership and intentional legacy routes',()=>{
|
||||
const f=workspaceFixture();
|
||||
assert.deepEqual(f.links.map(a=>a.getAttribute()),['#apps','#games','#tools','#settings']);
|
||||
f.navigate('');assert.equal(f.activePage,'apps');assert.equal(f.el('androidLibrary').hidden,false);assert.equal(f.el('appStore').hidden,true);
|
||||
f.navigate('#games');assert.equal(f.activePage,'games');assert.equal(f.el('getgames').hidden,true);assert.equal(f.el('library').hidden,false);assert.equal(f.el('sideloaded').hidden,false);
|
||||
f.navigate('#getgames');assert.equal(f.el('getgames').hidden,false);assert.equal(f.el('library').hidden,true);assert.equal(f.context.gm.src,'owned');
|
||||
f.navigate('#steamStore');assert.equal(f.context.gm.src,'store');assert.equal(f.el('getgames').hidden,false);
|
||||
f.navigate('#appStore');assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('androidLibrary').hidden,true);
|
||||
f.navigate('#linuxApps');assert.equal(f.activePage,'apps');assert.equal(f.el('appsLinux').hidden,false);
|
||||
f.navigate('#androidLibrary');assert.equal(f.el('appsLinux').hidden,true);assert.equal(f.el('androidLibrary').hidden,false);
|
||||
f.navigate('#display');assert.equal(f.activePage,'settings');assert.equal(f.el('androidSettings').hidden,false);assert.equal(f.el('settingsDirectory').hidden,true);
|
||||
f.navigate('#settings');assert.equal(f.el('settingsDirectory').hidden,false);assert.equal(f.el('settingsWorkspace').hidden,true);
|
||||
for(const [route,page,primary] of [['#devices','devices','settings'],['#control','control','settings'],['#shots','capture','tools'],['#view','capture','tools'],['#pad','tools','tools'],['#comfort','tools','tools'],['#library','games','games'],['#discover','apps','apps'],['#privacy','settings','settings']]){
|
||||
f.navigate(route);assert.equal(f.activePage,page,route);assert.equal(f.run('primaryPage(page)'),primary,route);
|
||||
}
|
||||
});
|
||||
test('app views and game scopes restore separate scroll positions, queries and filters',()=>{
|
||||
const f=workspaceFixture();f.navigate('#appStore');f.el('sourceQ').value='video';f.scroll(480);
|
||||
f.run("selectApps('installed')");f.el('libQ').value='player';f.scroll(220);
|
||||
f.navigate('#games');f.scroll(160);f.navigate('#apps');assert.equal(f.context.window.scrollY,220);
|
||||
f.run("selectApps('browse')");assert.equal(f.context.window.scrollY,480);assert.equal(f.el('sourceQ').value,'video');assert.equal(f.el('libQ').value,'player');
|
||||
f.navigate('#games');assert.equal(f.context.window.scrollY,160);
|
||||
f.run("selectLibrary('steam')");f.scroll(600);f.run("selectLibrary('sideloaded')");f.scroll(80);f.run("selectLibrary('steam')");assert.equal(f.context.window.scrollY,600);
|
||||
f.context.location.hash='#apps';f.scroll(0);f.run('showPage()');f.navigate('#games');assert.equal(f.context.window.scrollY,600);
|
||||
});
|
||||
test('Android browse opens Apps Browse after visiting the Steam store',()=>{
|
||||
const f=workspaceFixture();f.navigate('#steamStore');f.navigate('#androidLibrary');
|
||||
const start=html.indexOf('function showAndroidTab(tab)'),end=html.indexOf("document.querySelectorAll('[data-store-tab]')",start);
|
||||
f.run(html.slice(start,end));f.run("showAndroidTab('browse')");f.run('showPage()');
|
||||
assert.equal(f.activePage,'apps');assert.equal(f.el('appStore').hidden,false);
|
||||
});
|
||||
test('empty installed library opens Browse even when its route is already in the address bar',()=>{
|
||||
const f=workspaceFixture();let click;
|
||||
f.context.libState={q:'',filter:'all'};f.el('andApps').addEventListener=(name,fn)=>click=fn;
|
||||
const helper=html.indexOf('function showAndroidTab(tab)');
|
||||
f.run(html.slice(helper,html.indexOf("document.querySelectorAll('[data-store-tab]')",helper)));
|
||||
const start=html.indexOf('function renderLibrary()');
|
||||
f.run(html.slice(start,html.indexOf('document.addEventListener("click",',start)));
|
||||
f.navigate('#appStore');f.run("selectApps('installed');renderLibrary()");
|
||||
assert.match(f.el('andApps').innerHTML,/data-store-go="browse"/);assert.equal(f.el('appStore').hidden,true);
|
||||
click({target:{closest:()=>({dataset:{storeGo:'browse'}})}});
|
||||
assert.equal(f.context.location.hash,'#appStore');assert.equal(f.el('appStore').hidden,false);assert.equal(f.el('appsInstalled').hidden,true);
|
||||
});
|
||||
test('owned scope excludes installed games and keeps unknown compatibility visible',()=>{
|
||||
const f=workspaceFixture(),gm=f.context.gm;
|
||||
Object.assign(gm,{owned:[{id:1,installed:true,name:'Installed',frame:3,sort:'installed'},{id:2,installed:false,name:'Owned unknown',frame:0,sort:'owned',lastPlayed:0}],q:'',filters:{owned:new Set(['0','1','2','3'])},shown:0});
|
||||
f.context.moreGames=()=>{};
|
||||
const start=html.indexOf('function filterGames('),end=html.indexOf('function price(',start);
|
||||
f.run(html.slice(start,end));f.run('filterGames()');
|
||||
assert.deepEqual(Array.from(gm.results,g=>g.id),[2]);
|
||||
});
|
||||
test('installed load succeeds while optional catalog is unresolved, and errors keep local retry',async()=>{
|
||||
const f=workspaceFixture(),pending=deferred();
|
||||
let result={apps:[{package:'org.example.player',label:'Player',running:false}]},fail=false;
|
||||
f.context.api=path=>path==='/api/android'?fail?Promise.reject(Error('Temporary network failure')):Promise.resolve(result):pending.promise;
|
||||
f.context.renderLibrary=()=>f.el('andApps').innerHTML='installed';
|
||||
f.run('let androidSeq=0; const androidPending=new Set(); let installedPackage=null;');
|
||||
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||
f.run(html.slice(start,end));await f.run('loadAndroid()');
|
||||
assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,true);
|
||||
fail=true;await f.run('loadAndroid()');assert.equal(f.el('andApps').innerHTML,'installed');assert.equal(f.el('androidLoadError').hidden,false);
|
||||
assert.match(f.el('androidLoadMessage').textContent,/Temporary network failure/);
|
||||
fail=false;await f.run('loadAndroid()');assert.equal(f.el('androidLoadError').hidden,true);
|
||||
assert.ok(html.includes('loadAndroid();\n\n// ---- Android display'),'startup must not wait for catalog completion');
|
||||
});
|
||||
test('older installed responses cannot replace a newer read',async()=>{
|
||||
const f=workspaceFixture(),old=deferred(),next=deferred();let calls=0;
|
||||
f.context.api=()=>++calls===1?old.promise:next.promise;f.context.renderLibrary=()=>{};
|
||||
f.run('let androidSeq=0;const androidPending=new Set();let installedPackage=null;');
|
||||
const start=html.indexOf('async function loadAndroid() {'),end=html.indexOf("$('androidRetry').onclick",start);
|
||||
f.run(html.slice(start,end));const a=f.run('loadAndroid()'),b=f.run('loadAndroid()');
|
||||
next.resolve({apps:[{package:'new'}]});await b;old.resolve({apps:[{package:'old'}]});await a;
|
||||
assert.equal(f.context.androidApps[0].package,'new');
|
||||
});
|
||||
test('Android Open dispatch is deduplicated while pending and returns to an enabled row',async()=>{
|
||||
const f=workspaceFixture(),request=deferred(),writes=[];
|
||||
Object.assign(f.context,{devGen:1,installing:new Set(),toast(){},act:async(label,work)=>work(),
|
||||
api:(path,body)=>{writes.push({path,body});return request.promise;},loadAndroid:async()=>{},renderLibrary(){},cat:{apps:null},loadReports(){}});
|
||||
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||
const end=html.indexOf('loadAndroid();\n\n// ---- Android display',start);
|
||||
f.run(html.slice(start,end));
|
||||
const button={dataset:{and:'launch',pkg:'org.example.player',name:'Player'},disabled:false,textContent:'Open'};
|
||||
const event={target:{closest:()=>button}};
|
||||
const a=f.listeners['body-click'](event);await f.listeners['body-click'](event);
|
||||
assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');assert.equal(writes.length,1);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(writes[0])),{path:'/api/android',body:{action:'launch',package:'org.example.player'}});
|
||||
request.resolve({message:'Opened'});await a;assert.equal(button.disabled,false);assert.equal(button.textContent,'Open');assert.equal(button.ariaBusy,'false');
|
||||
});
|
||||
test('entering Apps Installed refreshes data without clearing cached rows',()=>{
|
||||
const f=workspaceFixture();f.el('andApps').innerHTML='cached installed apps';f.navigate('#games');f.navigate('#apps');
|
||||
assert.equal(f.calls.filter(c=>c[0]==='android').length,1);
|
||||
f.run("selectApps('installed')");assert.equal(f.calls.filter(c=>c[0]==='android').length,2);
|
||||
f.navigate('#settings');f.navigate('#apps');assert.equal(f.calls.filter(c=>c[0]==='android').length,3);
|
||||
assert.equal(f.el('andApps').innerHTML,'cached installed apps');
|
||||
});
|
||||
test('actions from detail synchronize existing rows and stay pending through the follow-up read',async()=>{
|
||||
for(const action of ['launch','stop']) {
|
||||
const f=workspaceFixture(),request=deferred(),refresh=deferred();let writes=0,renders=0;
|
||||
const makeButton=pkg=>({dataset:{and:action,pkg,name:'Player'},disabled:false,textContent:action==='stop'?'Stop':'Open'});
|
||||
const detail=makeButton('org.example.player'),row=makeButton('org.example.player'),other=makeButton('org.other');
|
||||
const originalQuery=f.context.document.querySelectorAll;
|
||||
f.context.document.querySelectorAll=selector=>selector==='[data-and="launch"], [data-and="stop"]'?[row,detail,other]:originalQuery(selector);
|
||||
Object.assign(f.context,{devGen:1,installing:new Set(),log(){},toast(){},api:()=>{writes++;return request.promise;},
|
||||
loadAndroid:()=>refresh.promise,renderLibrary(){renders++;row.disabled=false;row.textContent=action==='stop'?'Stop':'Open';row.ariaBusy='false';},cat:{apps:null},loadReports(){}});
|
||||
f.run('const androidPending=new Set();let installedPackage=null;');
|
||||
f.run(html.slice(html.indexOf('async function act('),html.indexOf('\nfunction setConn(')));
|
||||
const start=html.indexOf('document.body.addEventListener("click", async e => {',html.indexOf('$("catChips").onclick'));
|
||||
f.run(html.slice(start,html.indexOf('loadAndroid();\n\n// ---- Android display',start)));
|
||||
f.el('installedDetail').showModal();const pending=f.listeners['body-click']({target:{closest:()=>detail}});f.el('installedDetail').close();
|
||||
for(const button of [row,detail]){assert.equal(button.disabled,true);assert.equal(button.textContent,'Wait…');assert.equal(button.ariaBusy,'true');}
|
||||
assert.equal(other.disabled,false);await f.listeners['body-click']({target:{closest:()=>row}});assert.equal(writes,1);
|
||||
request.resolve({message:'Done'});await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(row.disabled,true);assert.equal(detail.disabled,true);assert.equal(renders,0,'stay pending while refreshed app state is unresolved');
|
||||
refresh.resolve();await pending;assert.equal(renders,1);assert.equal(row.disabled,false);assert.equal(detail.disabled,false);assert.equal(f.run('androidPending.size'),0);
|
||||
}
|
||||
});
|
||||
test('regenerated Android rows and details keep the compact accessible pending state',()=>{
|
||||
const f=workspaceFixture(),app={package:'org.example.player',label:'Player',running:false};
|
||||
Object.assign(f.context,{androidApps:[app],storeIcon:()=>'<span>icon</span>'});
|
||||
f.run("const androidPending=new Set(['org.example.player']);let installedPackage=null;");
|
||||
const start=html.indexOf('function libCard(a)'),end=html.indexOf('function renderLibrary()',start);f.run(html.slice(start,end));
|
||||
for(const running of [false,true]) {
|
||||
app.running=running;const row=f.run('libCard(androidApps[0])');f.run("openInstalledApp('org.example.player')");
|
||||
assert.match(row,/disabled aria-busy="true">Wait…<\/button>/);
|
||||
assert.match(f.el('installedDetailBody').innerHTML,/disabled aria-busy="true">Wait…<\/button>/);
|
||||
}
|
||||
f.run('androidPending.clear()');const row=f.run('libCard(androidApps[0])');assert.doesNotMatch(row,/aria-busy="true"/);assert.match(row,/>Stop<\/button>/);
|
||||
});
|
||||
test('explicit Capture section links take priority over saved destination scroll',()=>{
|
||||
const f=workspaceFixture();f.navigate('#capture');f.scroll(700);f.calls.length=0;
|
||||
f.navigate('#shots');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='shots'));
|
||||
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||
f.calls.length=0;f.navigate('#view');assert.ok(f.calls.some(c=>c[0]==='section'&&c[1]==='view'));
|
||||
});
|
||||
test('viewer keyboard shortcuts act only in the Capture workspace',()=>{
|
||||
const f=workspaceFixture(),events=[];let handler;
|
||||
f.context.document.activeElement={tagName:'BODY'};
|
||||
f.context.document.addEventListener=(name,callback)=>handler=callback;
|
||||
Object.assign(f.context,{ctrl:{on:false},zoom:1,setZoom:n=>events.push(['zoom',n])});
|
||||
f.el('ctrlBtn').click=()=>events.push(['control']);f.el('fullBtn').click=()=>events.push(['fullscreen']);
|
||||
const start=html.indexOf('document.addEventListener("keydown", e => {',html.indexOf('$("canvas").addEventListener("pointerdown"'));
|
||||
const end=html.indexOf('\n});',start)+4;f.run(html.slice(start,end));
|
||||
f.navigate('#capture');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||
f.navigate('#home');handler({key:'+'});handler({key:'c'});handler({key:'f'});assert.equal(events.length,3);
|
||||
});
|
||||
test('late Mac streaming responses cannot reveal a different tool panel',async()=>{
|
||||
for(const rejects of [false,true]) {
|
||||
const f=workspaceFixture(),response=deferred();
|
||||
Object.assign(f.context,{api:()=>response.promise,clearTimeout(){},setTimeout(){},failed(){}});
|
||||
f.run('let mvSeq=0,mvTimer=0;');
|
||||
const start=html.indexOf('async function loadMacView(restart)'),end=html.indexOf('$("mvRefresh").onclick',start);
|
||||
f.run(html.slice(start,end));f.context.location.hash='#macview';const pending=f.run('loadMacView()');
|
||||
f.navigate('#transfer');assert.equal(f.el('macview').hidden,true);
|
||||
rejects?response.reject(Error('Network failed')):response.resolve({available:true,streams:[],displays:[],windows:[]});
|
||||
await pending;assert.equal(f.el('macview').hidden,true);assert.equal(f.el('transfer').hidden,false);
|
||||
}
|
||||
});
|
||||
test('Home task count includes the current Steam download',()=>{
|
||||
const f=workspaceFixture();Object.assign(f.context,{state:{hostname:'Frame',battery:{percent:80}},stateRead:{valid:true},online:true});
|
||||
f.context.gm.dl={appid:1,percent:42};f.run('renderHome()');
|
||||
assert.equal(f.el('homeJobs').textContent,'1 task in progress');assert.match(f.el('homeDownload').textContent,/42%/);
|
||||
});
|
||||
test('a late screenshot read cannot replace newer navigation or clear a newer viewer request',async()=>{
|
||||
const f=workspaceFixture(),old=deferred(),newer=deferred();let reads=0;
|
||||
Object.assign(f.context,{shotBlob:()=>++reads===1?old.promise:newer.promise,URL:{createObjectURL:()=> 'blob:shot',revokeObjectURL(){}},
|
||||
Image:class{set src(value){this.onload();}},draw(){},shotApp:()=> 'Game',toast(){},desk:{shown:null}});
|
||||
const start=html.indexOf('async function openShot(s)'),end=html.indexOf('async function saveShots(',start);
|
||||
f.run(html.slice(start,end));f.navigate('#capture');const pending=f.run("openShot({id:1,file:'one.png',time:1})");
|
||||
assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||
f.navigate('#library');f.scroll(240);assert.equal(f.el('viewer').classList.contains('busy'),false);
|
||||
old.resolve({});await pending;assert.equal(f.activePage,'games');assert.equal(f.context.location.hash,'#library');assert.equal(f.context.window.scrollY,240);
|
||||
f.navigate('#capture');const first=f.run("openShot({id:2,file:'two.png',time:1})");
|
||||
f.navigate('#home');f.navigate('#capture');f.run('viewGen++');f.el('viewer').classList.toggle('busy',true);
|
||||
newer.resolve({});await first;assert.equal(f.activePage,'capture');assert.equal(f.context.location.hash,'#capture');assert.equal(f.el('viewer').classList.contains('busy'),true);
|
||||
});
|
||||
test('Steam discovery preserves an unsubmitted query across a source change',()=>{
|
||||
const f=workspaceFixture();Object.assign(f.context.gm,{src:'store',q:'owned query',storeDraft:'',storeQ:'submitted query'});
|
||||
f.context.renderChips=()=>{};f.context.filterGames=()=>{};
|
||||
const start=html.indexOf('function setGameSource(src)'),end=html.indexOf('$("gmSrc").onclick',start);
|
||||
f.run(html.slice(start,end));f.el('gmQ').value='unsubmitted search';f.run("setGameSource('owned')");
|
||||
assert.equal(f.el('gmQ').value,'owned query');f.run("setGameSource('store')");assert.equal(f.el('gmQ').value,'unsubmitted search');
|
||||
});
|
||||
test('expanded owned and store results survive source and destination returns',async()=>{
|
||||
const f=workspaceFixture(),gm=f.context.gm;
|
||||
const games=Array.from({length:120},(_,i)=>({id:i+1,name:'Game '+String(i+1).padStart(3,'0'),sort:String(i).padStart(3,'0'),frame:3,installed:false,lastPlayed:0}));
|
||||
Object.assign(gm,{owned:games,store:games,storeQ:'game',storeDraft:'game',country:'US',q:'',results:[],filters:{owned:new Set(['3']),store:new Set(['3'])},seq:0});
|
||||
Object.assign(f.context,{FLABEL:{3:'Verified'},FCLASS:{3:'verified'},CDN:'https://example.invalid',renderChips(){}});
|
||||
f.el('gmGrid').insertAdjacentHTML=(position,s)=>f.el('gmGrid').innerHTML+=s;
|
||||
const start=html.indexOf('function filterGames('),end=html.indexOf('$("gmSrc").onclick',start);
|
||||
f.run(html.slice(start,end));
|
||||
f.navigate('#getgames');f.run('filterGames();moreGames()');f.scroll(7000);
|
||||
assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);
|
||||
f.navigate('#steamStore');f.run('moreGames()');f.scroll(6500);
|
||||
assert.equal(gm.shown,96);f.navigate('#getgames');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,7000);
|
||||
f.navigate('#steamStore');assert.equal(gm.shown,96);assert.match(f.el('gmGrid').innerHTML,/Game 096/);assert.equal(f.context.window.scrollY,6500);
|
||||
f.run('filterGames()');assert.equal(gm.shown,48,'a deliberate filter change resets expansion');
|
||||
const response=deferred();f.context.api=()=>response.promise;f.el('gmQ').value='new search';const search=f.run('searchStore()');
|
||||
f.navigate('#getgames');const ownedMarkup=f.el('gmGrid').innerHTML;
|
||||
response.resolve({results:games.slice(0,60)});await search;
|
||||
assert.equal(f.el('gmGrid').innerHTML,ownedMarkup,'a late store response leaves current owned results intact');
|
||||
f.navigate('#steamStore');assert.equal(gm.shown,48);assert.equal(gm.storeQ,'new search');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Execute the production workspace code with only browser/transport boundaries replaced.
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
|
||||
function workspaceFixture() {
|
||||
const elements = new Map(), listeners = {}, calls = [];
|
||||
const classes = () => { const set=new Set(); return {add(k){set.add(k);},toggle(k,on){if(on===undefined)on=!set.has(k);on?set.add(k):set.delete(k);return on;},remove(k){set.delete(k);},contains:k=>set.has(k)}; };
|
||||
function el(id) {
|
||||
if (!elements.has(id)) elements.set(id,{id,hidden:false,disabled:false,open:false,innerHTML:'',textContent:'',value:'',dataset:{},classList:classes(),
|
||||
setAttribute(k,v){this[k]=v;},removeAttribute(k){delete this[k];},querySelectorAll(){return [];},
|
||||
append(node){node.parent=this;},prepend(node){node.parent=this;},scrollIntoView(){calls.push(['section',id]);},
|
||||
showModal(){this.open=true;},close(){this.open=false;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const pages=[...html.matchAll(/data-page="([^"]+)"/g)].map((m,i)=>Object.assign(el('page-'+i),{dataset:{page:m[1]}}));
|
||||
const nav=html.match(/<nav id="nav"[^>]*>([\s\S]*?)<\/nav>/)[1];
|
||||
const links=[...nav.matchAll(/<a href="([^"]+)"/g)].map((m,i)=>Object.assign(el('nav-'+i),{getAttribute:()=>m[1]}));
|
||||
const controls={};
|
||||
for (const [attr,values] of [['scope',['installed','owned','store']],['library-source',['all','steam','sideloaded']],['app-view',['installed','browse','linux']]]) {
|
||||
const prop=attr.replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
|
||||
controls[`[data-${attr}]`]=values.map(v=>Object.assign(el(attr+'-'+v),{dataset:{[prop]:v}}));
|
||||
}
|
||||
const win={scrollY:0,scrollTo(a,b){this.scrollY=typeof a==='object'?a.top:b;calls.push(['scroll',this.scrollY]);},addEventListener(k,fn){listeners[k]=fn;}};
|
||||
let hash='#apps';const location={get hash(){return hash;},set hash(value){hash=value&&!String(value).startsWith('#')?'#'+value:String(value);}};
|
||||
const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}},
|
||||
window:win,location,requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);},
|
||||
loadAndroid(){calls.push(['android']);},
|
||||
loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);},
|
||||
gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0,
|
||||
esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))};
|
||||
vm.createContext(context);
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
vm.runInContext(html.slice(start,end),context);
|
||||
return {html,context,elements,el,calls,pages,links,listeners,run:code=>vm.runInContext(code,context),
|
||||
navigate(hash){context.location.hash=hash;vm.runInContext('showPage()',context);},
|
||||
get activePage(){return pages.find(p=>p.classList.contains('on'))?.dataset.page;},
|
||||
scroll(y){win.scrollY=y;listeners.scroll();}};
|
||||
}
|
||||
module.exports={workspaceFixture,html};
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
|
||||
|
||||
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
|
||||
them for a headset instead: VR first, then games and the flat apps worth having in a panel
|
||||
(video, reading, making things). Apps built around a phone's hardware or system (calls and
|
||||
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
|
||||
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
|
||||
"""
|
||||
import re
|
||||
|
||||
# (id, label), in the order the store shows them.
|
||||
CATEGORIES = (
|
||||
('vr', 'VR'),
|
||||
('games', 'Games'),
|
||||
('emulators', 'Emulators'),
|
||||
('watch', 'Watch & listen'),
|
||||
('read', 'Read & learn'),
|
||||
('create', 'Create'),
|
||||
('work', 'Work'),
|
||||
('web', 'Web & chat'),
|
||||
('tools', 'Tools'),
|
||||
)
|
||||
IDS = {c for c, _ in CATEGORIES}
|
||||
|
||||
# F-Droid's category names -> a store category; None = only useful on a phone.
|
||||
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
|
||||
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
|
||||
BROAD = {
|
||||
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
|
||||
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
|
||||
'Development': 'tools', 'Security': 'tools',
|
||||
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
|
||||
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
|
||||
'System': None, 'Theming': None,
|
||||
}
|
||||
FINE = {
|
||||
'emulators': ('Emulator',),
|
||||
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
|
||||
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
|
||||
'Strategy Game', 'Visual Novel', 'Word Game'),
|
||||
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
|
||||
'Ambient Sound'),
|
||||
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
|
||||
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
|
||||
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
|
||||
'Bookmark'),
|
||||
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
|
||||
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
|
||||
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
|
||||
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
|
||||
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
|
||||
}
|
||||
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
|
||||
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
|
||||
PHONE_SYSTEM = {
|
||||
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
|
||||
'Theming', 'Emergency Action', 'Xposed', 'Automation',
|
||||
}
|
||||
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
|
||||
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
|
||||
# category says otherwise. A finer one when the source has them; a broad one when it only has
|
||||
# broad ones (IzzyOnDroid often does).
|
||||
PHONE_SIDE = {
|
||||
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
|
||||
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
|
||||
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
|
||||
'Connectivity',
|
||||
}
|
||||
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
|
||||
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
|
||||
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
|
||||
'Meditation', 'Mental Health', 'Sports & Health'}
|
||||
# When an app has several, the one that best describes it on a headset wins.
|
||||
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
|
||||
|
||||
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
|
||||
# What an app is, from its name: these are phone parts, whatever the categories say.
|
||||
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
|
||||
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
|
||||
r'magisk|xposed|battery)\b|\[root\]', re.I)
|
||||
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
|
||||
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
|
||||
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
|
||||
# Needs hardware or a device the Frame doesn't have, from the name or summary.
|
||||
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
|
||||
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
|
||||
|
||||
|
||||
def classify(entry):
|
||||
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
|
||||
if entry.get('vr') is True:
|
||||
return {'category': 'vr', 'phone_only': False}
|
||||
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
|
||||
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
|
||||
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
|
||||
broad = {BROAD[c] for c in names if BROAD.get(c)}
|
||||
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
|
||||
if category is None and EMULATOR.search(name + ' ' + summary):
|
||||
category = 'emulators'
|
||||
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
|
||||
useful = fine or (broad if only_broad else set())
|
||||
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
|
||||
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
|
||||
for c in names):
|
||||
phone = True
|
||||
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
|
||||
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
|
||||
return {'category': category, 'phone_only': phone}
|
||||
|
||||
|
||||
def matches(entry, category=None, phone=False):
|
||||
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
|
||||
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
|
||||
if entry.get('phone_only') and not phone:
|
||||
return False
|
||||
return not category or entry.get('category') == category
|
||||
+38
-11
@@ -12,7 +12,9 @@ from urllib.parse import urljoin, urlsplit
|
||||
from apk_sources import SourceError
|
||||
|
||||
_lock = threading.Lock()
|
||||
_urls = OrderedDict()
|
||||
_urls = OrderedDict() # token -> URL, oldest first
|
||||
_tokens = {} # URL -> token
|
||||
MAX_TOKENS = 16384
|
||||
_cache = OrderedDict()
|
||||
MAX_IMAGE = 8 * 1024 * 1024
|
||||
MAX_CACHE = 64 * 1024 * 1024
|
||||
@@ -31,24 +33,24 @@ def register(url):
|
||||
if not isinstance(url, str) or not valid_url(url):
|
||||
return None
|
||||
with _lock:
|
||||
for token, known in _urls.items():
|
||||
if known == url:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = _tokens.get(url)
|
||||
if token in _urls:
|
||||
_urls.move_to_end(token)
|
||||
return '/source-image/' + token
|
||||
token = secrets.token_urlsafe(24)
|
||||
_urls[token] = url
|
||||
while len(_urls) > 4096:
|
||||
_urls.popitem(last=False)
|
||||
_urls[token], _tokens[url] = url, token
|
||||
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
|
||||
_tokens.pop(_urls.popitem(last=False)[1], None)
|
||||
return '/source-image/' + token
|
||||
|
||||
|
||||
def artwork(entry):
|
||||
def artwork(entry, shots=12):
|
||||
images = entry.get('images') or {}
|
||||
if not isinstance(images, dict):
|
||||
images = {}
|
||||
return {'icon': register(images.get('icon') or entry.get('icon')),
|
||||
'banner': register(images.get('banner')),
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
|
||||
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
|
||||
|
||||
|
||||
def image_type(data):
|
||||
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||
|
||||
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
|
||||
_resolvers = threading.BoundedSemaphore(4)
|
||||
# A page of store cards asks for dozens of images from the same few hosts at once: their public
|
||||
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
|
||||
_dns = {} # (host, port) -> (addresses, expires)
|
||||
DNS_TTL = 300
|
||||
|
||||
|
||||
def _resolve(host, port, timeout):
|
||||
with _lock:
|
||||
known = _dns.get((host, port))
|
||||
if known and known[1] > time.monotonic():
|
||||
return known[0]
|
||||
found = _lookup(host, port, timeout)
|
||||
try:
|
||||
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
|
||||
except (TypeError, ValueError, IndexError):
|
||||
public = False
|
||||
if public: # private answers are refused by the caller, and never kept
|
||||
with _lock:
|
||||
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
|
||||
while len(_dns) > 256:
|
||||
_dns.pop(next(iter(_dns)))
|
||||
return found
|
||||
|
||||
|
||||
def _lookup(host, port, timeout):
|
||||
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||
if not _resolvers.acquire(blocking=False):
|
||||
# A request waits for a free slot within its own budget rather than failing at once.
|
||||
started = time.monotonic()
|
||||
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
|
||||
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
|
||||
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
|
||||
found = {}
|
||||
|
||||
def run():
|
||||
|
||||
@@ -26,7 +26,8 @@ from frame_apk_sign import _der_parts, _cert_key, der
|
||||
from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
CACHE_VERSION = 3 # 3: categories
|
||||
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
_refreshing = {} # source id -> background refresh thread
|
||||
@@ -431,6 +432,7 @@ def _reduce(path, source):
|
||||
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
|
||||
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
|
||||
page=meta.get('webSite') or source['url'], vr=None, free=True,
|
||||
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
|
||||
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
|
||||
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
|
||||
return result
|
||||
@@ -449,6 +451,7 @@ def _v1(content, path):
|
||||
localized = app.get('localized', {})
|
||||
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
|
||||
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
|
||||
meta['categories'] = app.get('categories') or []
|
||||
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
|
||||
images = {}
|
||||
for locale, fields in localized.items():
|
||||
@@ -530,7 +533,7 @@ def _load(source, force=False):
|
||||
return found[:2]
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
v2 = True
|
||||
try:
|
||||
@@ -624,16 +627,39 @@ def set_enabled(source_id, enabled):
|
||||
_write(_storage(), settings)
|
||||
|
||||
|
||||
def search(source, query, limit=50):
|
||||
def _showcase(a):
|
||||
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
|
||||
images = a.get('images') or {}
|
||||
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
|
||||
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
|
||||
|
||||
|
||||
def search(source, query, limit=50, keep=None):
|
||||
"""keep: optional filter applied before the limit (the store's category browse)."""
|
||||
if not source.get('enabled', True):
|
||||
return []
|
||||
apps, _ = _load(source)
|
||||
words = query.casefold().split()
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
|
||||
and (keep is None or keep(a))]
|
||||
if words:
|
||||
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
|
||||
else:
|
||||
found.sort(key=_showcase)
|
||||
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
|
||||
|
||||
|
||||
def lookup(source, packages):
|
||||
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
|
||||
if not source.get('enabled', True):
|
||||
return {}
|
||||
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
|
||||
if not found:
|
||||
return {}
|
||||
apps = found[0]
|
||||
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
|
||||
|
||||
|
||||
def details(source, entry_id):
|
||||
if not source.get('enabled', True):
|
||||
raise SourceError('repository is disabled')
|
||||
|
||||
+58
-14
@@ -15,7 +15,7 @@ import time
|
||||
import unicodedata
|
||||
|
||||
import frame_host
|
||||
from apk_sources import SourceError, SourceLimited
|
||||
from apk_sources import SourceError, SourceLimited, _categories as categories
|
||||
|
||||
_lock = threading.RLock()
|
||||
_running = {}
|
||||
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
|
||||
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
|
||||
_status = {}
|
||||
TIMEOUT = 12
|
||||
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
|
||||
|
||||
|
||||
def modules():
|
||||
@@ -158,9 +159,11 @@ def verdict(entry):
|
||||
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
|
||||
|
||||
|
||||
def decorate(entry):
|
||||
def decorate(entry, shots=12):
|
||||
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
|
||||
from apk_sources import _images
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
|
||||
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
|
||||
**categories.classify(entry))
|
||||
|
||||
|
||||
def details(source_id, entry_id):
|
||||
@@ -178,10 +181,12 @@ def offer_rank(entry):
|
||||
entry.get('trust') == 'official')
|
||||
|
||||
|
||||
def group(entries, query='', vr=None, installable=False):
|
||||
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
|
||||
groups = {}
|
||||
for entry in entries:
|
||||
entry = decorate(entry)
|
||||
entry = decorate(entry, shots=3)
|
||||
if not categories.matches(entry, category, phone):
|
||||
continue
|
||||
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
|
||||
continue
|
||||
if installable and entry['fit']['installable'] is not True:
|
||||
@@ -195,7 +200,7 @@ def group(entries, query='', vr=None, installable=False):
|
||||
offers.sort(key=offer_rank, reverse=True)
|
||||
best = offers[0]
|
||||
result.append({'name': best.get('name'), 'package': best.get('package'),
|
||||
'summary': best.get('summary'), 'offers': offers})
|
||||
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
|
||||
q = normalise(query)
|
||||
|
||||
def browse(a): # a headset store: VR first, then apps with artwork, newest first
|
||||
@@ -215,10 +220,10 @@ def group(entries, query='', vr=None, installable=False):
|
||||
return result
|
||||
|
||||
|
||||
def _launch(module, source, query, limit):
|
||||
def _launch(module, source, query, limit, category=None, phone=False):
|
||||
"""One search per source at a time; the newest different query runs next."""
|
||||
key = source['id']
|
||||
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
|
||||
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
|
||||
'job': (module, source)}
|
||||
with _lock:
|
||||
old = _running.get(key)
|
||||
@@ -237,13 +242,17 @@ def _launch(module, source, query, limit):
|
||||
|
||||
def _start(key, task):
|
||||
module, source = task['job']
|
||||
query, limit = task['query']
|
||||
query, limit, category, phone = task['query']
|
||||
kwargs = {}
|
||||
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
|
||||
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
|
||||
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
|
||||
|
||||
def run():
|
||||
queued = None
|
||||
try:
|
||||
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
|
||||
for e in module.search(source, query, limit=limit) if e.get('free') is True]
|
||||
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
|
||||
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
|
||||
except Exception as e:
|
||||
task['error'] = str(e)
|
||||
@@ -259,14 +268,20 @@ def _start(key, task):
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
|
||||
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
|
||||
items, errors = registry()
|
||||
if source and source not in [s['id'] for _, s in items]:
|
||||
raise SourceError('Unknown source')
|
||||
if category and category not in categories.IDS:
|
||||
raise SourceError('Unknown category')
|
||||
if not query.strip():
|
||||
limit = max(limit, BROWSE_LIMIT)
|
||||
else:
|
||||
phone = True # the phone-only guess hides apps from browsing, never from a search for them
|
||||
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
|
||||
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
|
||||
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
|
||||
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
|
||||
entries, statuses = [], list(errors)
|
||||
for s, task in tasks:
|
||||
status = {'id': s['id'], 'name': s['name']}
|
||||
@@ -281,7 +296,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
|
||||
statuses.append(status)
|
||||
with _lock:
|
||||
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
|
||||
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
|
||||
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
|
||||
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
|
||||
|
||||
|
||||
def warm():
|
||||
@@ -291,7 +307,35 @@ def warm():
|
||||
items, _ = registry()
|
||||
for m, s in items:
|
||||
if s['enabled'] and not s.get('page_only'):
|
||||
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
|
||||
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
|
||||
|
||||
|
||||
def artwork_for(apps):
|
||||
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
|
||||
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
|
||||
from apk_sources import _images
|
||||
items, _ = registry()
|
||||
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
|
||||
found = {}
|
||||
for module, source in items:
|
||||
if not source['enabled'] or source.get('page_only'):
|
||||
continue
|
||||
try:
|
||||
if hasattr(module, 'lookup'):
|
||||
hits = module.lookup(source, [p for p in wanted if p not in found])
|
||||
elif module.KIND == 'github': # a small local list; its entries have names, not packages
|
||||
names = {n: p for p, n in wanted.items() if n and p not in found}
|
||||
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
|
||||
if normalise(e.get('name')) in names}
|
||||
else:
|
||||
continue
|
||||
except (SourceError, OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
for package, entry in hits.items():
|
||||
art = _images.artwork(entry)
|
||||
if art['icon'] or art['banner'] or art['screenshots']:
|
||||
found.setdefault(package, art)
|
||||
return found
|
||||
|
||||
|
||||
def install(source_id, entry_id, version_code=None, progress=None):
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user