mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-12 21:01:08 +02:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 | ||
|
|
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 | ||
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
edbe8d4109 | ||
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
63c1a9ff55 | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
7308ac09b1 | ||
|
|
1a5f089e57 | ||
|
|
83d74548cd |
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
|
||||
@@ -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 : ""}`);
|
||||
}
|
||||
+28
-6
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -149,7 +149,7 @@ async function startServer() {
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
@@ -175,18 +175,27 @@ function stopServer() {
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
function errorPage(message, title = "Frame Control couldn't start") {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||
<p>${esc(message)}</p>
|
||||
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
// A server that had been running starts again by itself (something stopped it: a
|
||||
// signal, a crash). One that stops again within a minute shows the error instead,
|
||||
// so a server that can't stay up doesn't restart forever.
|
||||
let serverStarted = 0;
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
if (!win) return;
|
||||
if (Date.now() - serverStarted > 60000) restartServer();
|
||||
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||
}
|
||||
|
||||
// Restarts that overlap share one: two could each start a server, and the one
|
||||
@@ -263,7 +272,20 @@ function fromUi(e) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// The error page's Try Again button. The error page is the only data: page the window
|
||||
// shows (`url` can still be set then: the server answered but the page failed to load).
|
||||
ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
||||
if (img.isEmpty()) throw new Error("not an image");
|
||||
clipboard.writeImage(img);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.3",
|
||||
"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"
|
||||
@@ -81,7 +82,7 @@
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"XrApiLayer_FRAME_compat.json",
|
||||
"prebuilt/**/*.so"
|
||||
"prebuilt/**/*.so.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+4
-2
@@ -2,8 +2,8 @@
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached, and keeps the
|
||||
// Frame menu's list of headsets up to date.
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
@@ -12,7 +12,9 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
|
||||
@@ -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 })) };
|
||||
}
|
||||
|
||||
+59
-21
@@ -3,9 +3,8 @@
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
@@ -58,7 +57,7 @@ and ranks them:
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
@@ -73,16 +72,6 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
@@ -115,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>
|
||||
@@ -132,10 +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** (Devices tab → **Advanced**, with the
|
||||
SSH alias, user and port) 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
|
||||
|
||||
@@ -156,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
|
||||
@@ -173,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",
|
||||
|
||||
+10
-5
@@ -22,9 +22,9 @@ The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset and whether it's
|
||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||
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
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
@@ -88,8 +88,13 @@ counts them while they run.
|
||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
asks for the sudo password over SSH.
|
||||
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||
sign in with the Developer Mode password. Sleep, restart and
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
+133
-11
@@ -43,13 +43,20 @@ computer, exactly as they were sent.
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `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`, `frame_not_set_up`, `frame_auth`,
|
||||
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
|
||||
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
|
||||
Control's OpenXR compatibility layer, because this copy of the app is missing
|
||||
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
|
||||
names what was installed only when that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
@@ -93,19 +100,32 @@ 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
|
||||
|
||||
**Report a problem** is the speech-bubble button in the header, also in the
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||
same way as the analytics above, so only the maintainer can read it and
|
||||
nothing is published. It works whatever the analytics settings are, because
|
||||
the person sends it deliberately. The report has the kind, title and text you
|
||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||
email address goes with it only if you tick **The maintainer may contact me
|
||||
with follow-up questions** (the report then carries `contact_followup: true`);
|
||||
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
|
||||
your analytics events. With that box ticked, the address also becomes your
|
||||
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||
like any other. If it's a different address from the one saved there, it
|
||||
replaces it, and update notices stop until you turn them on again (they were
|
||||
agreed for the old address); the form says so before you send. The report then also
|
||||
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||
see below), so removing or changing the address later takes back the
|
||||
follow-up permission given with the report too.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
@@ -113,10 +133,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
|
||||
@@ -128,11 +194,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Contact email (optional)
|
||||
|
||||
Frame Control never needs an email address. If you'd like to leave one, there
|
||||
are two separate choices, both off until you tick them:
|
||||
|
||||
| Choice | What it's for |
|
||||
|---|---|
|
||||
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
You're asked once, in a bar at the top of the page, after the Frame has
|
||||
connected for the first time, and never in the same visit as the first-run
|
||||
privacy notice. **No thanks** hides it for good, and it isn't
|
||||
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||
is where you add, change or remove the address and either choice at any time.
|
||||
|
||||
**What's sent, and where.** The address and the two choices go privately to
|
||||
Frame Control's PostHog project, the same place as problem reports, as a
|
||||
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
|
||||
or `withdraw`) and the common properties above. Only the maintainer can read
|
||||
that project, and nothing in it is published or shared. It's sent only when
|
||||
you save, or when you send a problem report with follow-up questions ticked,
|
||||
whatever the analytics settings are, because you chose to. With a report, the
|
||||
address and choices are saved before the report is sent and stay saved if it
|
||||
fails; like any change, they're sent as soon as PostHog can be reached. It
|
||||
carries its own random contact id, not the analytics id, so it isn't linked
|
||||
to your usage events, and a `rev` number that goes up with each change, so
|
||||
the newest choice always wins. Like everything else sent, it's listed under
|
||||
**Show what's been sent**. On this computer the address and choices are kept in
|
||||
`contact/contact.json` in Frame Control's data folder. An address is only
|
||||
kept with at least one choice ticked.
|
||||
|
||||
**Removing it.** **Remove my email** (or clearing the address and saving)
|
||||
deletes it from this computer, including from the **Show what's been sent**
|
||||
log (in earlier contact events and problem reports), and sends a `withdraw`
|
||||
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
|
||||
then on the address isn't listed for either choice. Unticking one choice
|
||||
works the same way for that choice. This also covers problem reports you sent
|
||||
from this copy with follow-up questions ticked: if your newest choice since the
|
||||
report (by change number, not the clock) no longer agrees to follow-up
|
||||
questions at that address, the maintainer's inbox shows the permission as
|
||||
withdrawn and leaves the address out. If you're offline, the change waits on
|
||||
this computer and is sent when PostHog can be reached. The earlier event
|
||||
stays in PostHog until its data retention removes it; to have it deleted
|
||||
sooner, ask the maintainer (for example in a problem report).
|
||||
|
||||
Nothing sends email yet: this only records who agreed to what. The
|
||||
maintainer lists the addresses with
|
||||
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||
personal API key as `inbox`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||
the environment that starts Frame Control. A copy run from a source checkout
|
||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
|
||||
These switches cover the analytics above. A problem report or a contact email
|
||||
is sent only because you pressed its Send or Save button, so those still go
|
||||
when you choose to send them (a contact change saved while offline is sent
|
||||
by itself once PostHog can be reached); if you don't, nothing is sent.
|
||||
|
||||
## Update checks
|
||||
|
||||
|
||||
+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).
|
||||
|
||||
|
||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
|
||||
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||
- Closing the client leaves the session running, and the next login
|
||||
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||
gamescope or SteamVR session.
|
||||
|
||||
## B. Show the Mac's desktop inside the Frame
|
||||
|
||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||
@@ -247,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).
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||
|
||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||
it running. This mode also returned live timing in a read-only device probe.
|
||||
|
||||
@@ -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.
+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"
|
||||
@@ -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()
|
||||
+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("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,93 @@
|
||||
// The Android store's app page, run in node against the real functions from ui/index.html.
|
||||
// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the
|
||||
// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now.
|
||||
// (tests/test_server.py PageScripts checks no top-level name is declared twice.)
|
||||
import { readFileSync } from "fs";
|
||||
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
|
||||
const lines = src.split("\n");
|
||||
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
|
||||
|
||||
// A top-level declaration, from its first line to where its braces balance.
|
||||
function grab(name) {
|
||||
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
|
||||
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||
}
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
// A fake page: elements by id. The install panel's elements exist only while
|
||||
// #detailBody holds the real app page, as in the browser.
|
||||
function page(api = async () => ({})) {
|
||||
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
|
||||
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
|
||||
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
|
||||
: (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] });
|
||||
const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() };
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
|
||||
return { els, sourceState, calls, ...fns };
|
||||
}
|
||||
|
||||
const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR",
|
||||
description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush",
|
||||
artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } };
|
||||
|
||||
// An app from the catalogue: its page renders, with its name, install and sources.
|
||||
{
|
||||
const p = page();
|
||||
p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== true) fail("a good listing reported a render failure");
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200));
|
||||
if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel");
|
||||
if (html.includes('role="alert"')) fail("a good listing showed the error state");
|
||||
}
|
||||
|
||||
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
|
||||
{
|
||||
const p = page();
|
||||
p.sourceState.detail = { app: { name: "Broken <App>", offers: [] }, choice: 0 };
|
||||
if (p.renderAppDetail() !== false) fail("a broken listing reported success");
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken <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("/"))
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
|
||||
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import frame_compat_db as db # noqa: E402
|
||||
import frame_contact as fc # noqa: E402
|
||||
import frame_report as fr # noqa: E402
|
||||
import frame_telemetry as tm # noqa: E402
|
||||
from test_telemetry import Base, ReportProblem # noqa: E402
|
||||
|
||||
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
|
||||
serve = ReportProblem.serve
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.addCleanup(fc._removed.clear)
|
||||
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
|
||||
p = mock.patch.object(fc, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.got = self.serve()
|
||||
|
||||
def events(self):
|
||||
return [body["batch"][0] for _, body in self.got]
|
||||
|
||||
def offline(self):
|
||||
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
|
||||
|
||||
# ---- storage and consent flags
|
||||
|
||||
def test_nothing_is_kept_or_sent_until_chosen(self):
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
|
||||
self.assertFalse(fc.FILE.exists())
|
||||
self.assertEqual(self.got, [])
|
||||
|
||||
def test_an_address_needs_a_choice_and_a_real_address(self):
|
||||
with self.assertRaisesRegex(ValueError, "tick"):
|
||||
fc.save({"email": "me@example.com"})
|
||||
with self.assertRaisesRegex(ValueError, "email address"):
|
||||
fc.save({"email": "not an address", "updates": True})
|
||||
self.assertEqual(fc.load()["email"], "")
|
||||
self.assertEqual(self.got, [])
|
||||
|
||||
def test_only_a_real_true_counts_as_consent(self):
|
||||
for wrong in ("false", "true", 1, 0, [], {}):
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
||||
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
||||
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
||||
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
||||
|
||||
def test_each_choice_is_sent_privately_on_its_own(self):
|
||||
fc.save({"email": " me@example.com ", "updates": True})
|
||||
fc.save({"email": "me@example.com", "updates": False, "followup": True})
|
||||
first, second = self.events()
|
||||
self.assertEqual(first["event"], "contact_consent")
|
||||
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
|
||||
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
|
||||
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
|
||||
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
|
||||
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
|
||||
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
|
||||
(False, True))
|
||||
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
|
||||
|
||||
def test_sent_whatever_the_analytics_settings(self):
|
||||
tm.update_settings({"usage": False})
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
def test_saving_the_same_choice_again_sends_nothing(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
# ---- withdrawal
|
||||
|
||||
def test_removing_the_address_sends_a_withdrawal_without_it(self):
|
||||
fc.save({"email": "me@example.com", "updates": True, "followup": True})
|
||||
s = fc.save({"email": "", "updates": True, "followup": True})
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
|
||||
withdrawal = self.events()[-1]["properties"]
|
||||
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
|
||||
("withdraw", "", False, False))
|
||||
self.assertNotIn("me@example.com", fc.FILE.read_text())
|
||||
|
||||
def test_an_address_still_waiting_is_withdrawn_too(self):
|
||||
with self.offline():
|
||||
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
|
||||
with mock.patch.object(tm, "post") as post:
|
||||
fc.save({"email": ""})
|
||||
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
|
||||
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
with self.offline():
|
||||
s = fc.save({"email": ""})
|
||||
self.assertTrue(s["waiting"])
|
||||
self.assertFalse(fc._send_pending())
|
||||
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
||||
|
||||
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertIn("me@example.com", tm.SENT.read_text())
|
||||
fc.save({"email": ""})
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
|
||||
if e["event"] == "contact_consent"], ["set", "withdraw"])
|
||||
|
||||
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
|
||||
fc.save({"email": "me@example.com", "updates": True})
|
||||
fc.save({"email": "new@example.com", "updates": True})
|
||||
fc.save({"email": ""})
|
||||
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
|
||||
|
||||
def test_a_withdrawal_during_a_send_goes_after_it(self):
|
||||
started, release, order = threading.Event(), threading.Event(), []
|
||||
real = tm.post
|
||||
|
||||
def slow(batch, timeout=20):
|
||||
order.append(batch[0]["properties"]["action"])
|
||||
if len(order) == 1:
|
||||
started.set()
|
||||
release.wait(5)
|
||||
real(batch, timeout)
|
||||
|
||||
with mock.patch.object(tm, "post", side_effect=slow):
|
||||
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
||||
t.start()
|
||||
self.assertTrue(started.wait(5))
|
||||
w = threading.Thread(target=fc.save, args=({"email": ""},))
|
||||
w.start()
|
||||
for _ in range(500): # the withdrawal is saved while the first send is still out
|
||||
if fc.load()["rev"] == 2:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
|
||||
release.set()
|
||||
t.join(5)
|
||||
w.join(5)
|
||||
self.assertEqual(order, ["set", "withdraw"])
|
||||
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
|
||||
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
|
||||
fc.save({"email": "me@example.com", "followup": True})
|
||||
real = tm.post
|
||||
|
||||
def remove_meanwhile(batch, timeout=20):
|
||||
real(batch, timeout)
|
||||
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
|
||||
|
||||
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertNotIn("me@example.com", tm.SENT.read_text())
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
|
||||
|
||||
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
|
||||
fc._removed["me@example.com"] = 1790000000.3
|
||||
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
|
||||
before, after = event(), event() # the same whole second as the removal
|
||||
fc.redact_removed(before, 1790000000.1)
|
||||
fc.redact_removed(after, 1790000000.6)
|
||||
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
|
||||
("<removed>", "me@example.com"))
|
||||
|
||||
def test_saving_during_a_slow_send_returns_at_once(self):
|
||||
busy = fc._send_lock
|
||||
busy.acquire()
|
||||
try:
|
||||
s = fc.save({"email": "me@example.com", "updates": True})
|
||||
finally:
|
||||
busy.release()
|
||||
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
|
||||
self.assertEqual(self.got, [])
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertEqual(len(self.got), 1)
|
||||
|
||||
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
|
||||
real = fc._send_lock
|
||||
|
||||
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
|
||||
saved = False
|
||||
|
||||
def acquire(self, blocking=True):
|
||||
return real.acquire(blocking)
|
||||
|
||||
def release(self):
|
||||
if not Lock.saved:
|
||||
Lock.saved = True
|
||||
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
|
||||
s.start()
|
||||
s.join(5)
|
||||
assert not s.is_alive() # the change is saved while the sender still holds the lock
|
||||
real.release()
|
||||
|
||||
with mock.patch.object(fc, "_send_lock", Lock()):
|
||||
self.assertTrue(fc._send_pending())
|
||||
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
|
||||
self.assertFalse(fc.state()["waiting"])
|
||||
|
||||
# ---- the one-time prompt
|
||||
|
||||
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
|
||||
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
self.assertTrue(fc.state()["showPrompt"])
|
||||
fc.prompt({"prompt": "dismissed"})
|
||||
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
||||
self.assertEqual(fc.load()["prompt"], "dismissed")
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
self.assertEqual(self.got, []) # No thanks sends nothing
|
||||
with self.assertRaises(ValueError):
|
||||
fc.prompt({"prompt": "reset"})
|
||||
|
||||
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
fc.prompt({"prompt": "shown"})
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
|
||||
self.assertEqual(fc.load()["prompt"], "answered")
|
||||
|
||||
# ---- reports and the maintainer's list
|
||||
|
||||
def reports(self):
|
||||
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
||||
|
||||
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com"})
|
||||
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
without, with_ = self.reports()
|
||||
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
||||
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
||||
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
||||
with self.assertRaisesRegex(ValueError, "email address"):
|
||||
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||
|
||||
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
||||
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
||||
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
||||
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
||||
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
||||
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
||||
fc.save({"email": ""}) # Remove my email
|
||||
last = self.events()[-1]
|
||||
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
||||
("withdraw", "", 2))
|
||||
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>", "<removed>"])
|
||||
|
||||
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
||||
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
||||
fc.save({"email": "old@example.com", "updates": True})
|
||||
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
||||
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
||||
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
||||
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
||||
|
||||
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
||||
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
||||
rev (so the removal is newer) and is logged without the address."""
|
||||
post, removed = tm.post, []
|
||||
|
||||
def slow_post(events, **kw):
|
||||
post(events, **kw)
|
||||
if not removed and events[0]["event"] == "contact_consent":
|
||||
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
||||
with mock.patch.object(tm, "post", side_effect=slow_post):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
report = self.reports()[0]
|
||||
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>"])
|
||||
|
||||
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
||||
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
||||
"0.4.0", "Windows", "", "", followup, cid, rev]
|
||||
|
||||
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
||||
reports = [self.report_row(), # removed later
|
||||
self.report_row(cid="other"), # another copy, still agrees
|
||||
self.report_row(rev=3), # sent after the removal
|
||||
self.report_row(cid="moved"), # address changed later
|
||||
self.report_row(cid="news-only"), # follow-up unticked later
|
||||
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
||||
self.report_row(cid="", followup=True), # no contact id: left alone
|
||||
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
||||
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
||||
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
||||
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
||||
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
||||
fr.mark_withdrawn(reports, consents)
|
||||
self.assertEqual([r[10] for r in reports],
|
||||
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
||||
|
||||
def test_the_change_number_decides_not_the_clock(self):
|
||||
"""The clock went back between the report and the removal: the removal still counts."""
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
||||
fc.save({"email": ""})
|
||||
report = self.reports()[0]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
|
||||
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
||||
reports = [self.report_row(), ["short"]]
|
||||
consents = [["copy", "", False, 2]]
|
||||
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
||||
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("follow-up permission since withdrawn", printed)
|
||||
self.assertNotIn("me@example.com", printed)
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
||||
fr.inbox()
|
||||
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
||||
|
||||
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
|
||||
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
|
||||
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
|
||||
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
|
||||
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
|
||||
found = fr.contacts()
|
||||
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
|
||||
q.call_args.args[0])
|
||||
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
|
||||
"followup": [("both@example.com", "2026-09-01")]})
|
||||
with mock.patch.object(fr, "contacts", return_value=found), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("both@example.com", printed)
|
||||
self.assertNotIn("news@example.com", printed)
|
||||
|
||||
def test_the_page_can_reach_it(self):
|
||||
import server
|
||||
self.assertIs(server.POST["/api/contact"], fc.save)
|
||||
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
|
||||
|
||||
def test_saving_is_not_headset_work(self):
|
||||
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
||||
import io
|
||||
import server
|
||||
seen = []
|
||||
for path in ("/api/contact", "/api/contact/prompt"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
||||
h.path, h.rfile = path, io.BytesIO(body)
|
||||
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
||||
h.local_request = lambda: True
|
||||
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
||||
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
||||
h.do_POST()
|
||||
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
||||
|
||||
|
||||
# Run these once, in test_telemetry, not again through the import above.
|
||||
del Base, ReportProblem
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
-16
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_devices as fd # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
|
||||
CONFIG = """Host lxso1
|
||||
HostName 192.168.1.109
|
||||
@@ -263,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"])
|
||||
@@ -274,7 +299,8 @@ class Pins(Base):
|
||||
def test_hashed_and_non_default_port_entries(self):
|
||||
kh = self.ssh / "known_hosts"
|
||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||
@@ -283,7 +309,8 @@ class Pins(Base):
|
||||
target = fd.known_hosts("d4")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
self.assertNotIn("frame-control-d4", target.read_text())
|
||||
self.assertTrue(fd.pinned("d4"))
|
||||
self.assertTrue(fd.forget_pin("d4"))
|
||||
@@ -296,20 +323,6 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||
addrs = self.reg.get(d["id"])["addresses"]
|
||||
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||
self.assertEqual(at_home[0], "192.168.1.40")
|
||||
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||
|
||||
def test_address_editing(self):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline authenticated repository fixtures; no tests contact a server."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -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
|
||||
@@ -63,7 +64,7 @@ class ObbTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||
data.install_obb(PKG, [path])
|
||||
stream.assert_not_called()
|
||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||
data._stream('command')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -263,11 +264,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()
|
||||
|
||||
+1
-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):
|
||||
|
||||
@@ -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')):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||
import sandbox # noqa: F401
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||
import frame_host
|
||||
|
||||
|
||||
class CapturedSSH(unittest.TestCase):
|
||||
def run_command(self, source, **kwargs):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||
|
||||
def test_binary_output_and_input(self):
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||
capture_output=True, input=b"data\x00\xff")
|
||||
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||
self.assertEqual(result.stderr, b"error\r\n")
|
||||
|
||||
def test_text_output_normalizes_newlines(self):
|
||||
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||
capture_output=True, input="hello\n", text=True)
|
||||
self.assertEqual(result.stdout, "hello\n")
|
||||
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||
|
||||
def test_explicit_encoding_and_errors(self):
|
||||
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||
capture_output=True, encoding="ascii", errors="replace")
|
||||
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||
|
||||
def test_check_preserves_error_output(self):
|
||||
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||
capture_output=True, text=True, check=True)
|
||||
self.assertEqual(caught.exception.returncode, 7)
|
||||
self.assertEqual(caught.exception.stdout, "out\n")
|
||||
self.assertEqual(caught.exception.stderr, "err\n")
|
||||
|
||||
def test_timeout_preserves_partial_stderr(self):
|
||||
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||
capture_output=True, text=True, timeout=1)
|
||||
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||
|
||||
def test_streamed_stdout_is_kept_separate(self):
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||
"sys.stderr.buffer.write(b'error')",
|
||||
stdout=output, stderr=subprocess.PIPE)
|
||||
output.seek(0)
|
||||
self.assertEqual(output.read(), b"file")
|
||||
self.assertIsNone(result.stdout)
|
||||
self.assertEqual(result.stderr, b"error")
|
||||
|
||||
def test_uncaptured_windows_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
|
||||
def test_posix_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
|
||||
def test_capture_rejects_explicit_streams(self):
|
||||
for stream in ("stdout", "stderr"):
|
||||
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 255)
|
||||
self.assertIn("Could not resolve hostname", result.stderr)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+683
-46
@@ -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
|
||||
@@ -198,7 +200,7 @@ class Connecting(unittest.TestCase):
|
||||
rows = {p["host"]: p for p in s["probes"]}
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||
self.assertIn("different headset", rows["::1"]["detail"])
|
||||
self.assertIn("different SSH identity", rows["::1"]["detail"])
|
||||
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-t")
|
||||
@@ -207,9 +209,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 +259,7 @@ 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
|
||||
|
||||
def test_pinned_identity_is_checked_strictly(self):
|
||||
d = self.device("localhost")
|
||||
@@ -265,6 +269,426 @@ 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"])
|
||||
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(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"})
|
||||
@@ -457,47 +881,6 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual(self.routes, [])
|
||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||
|
||||
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||
d = self.device("nothing.invalid")
|
||||
entered = {1: threading.Event(), 2: threading.Event()}
|
||||
release = {1: threading.Event(), 2: threading.Event()}
|
||||
calls = []
|
||||
|
||||
def probe(host, port, update=None):
|
||||
calls.append(host)
|
||||
n = len(calls)
|
||||
entered[n].set()
|
||||
release[n].wait(10)
|
||||
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||
|
||||
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||
with mock.patch.object(fl, "probe", probe):
|
||||
try:
|
||||
tests[0].start()
|
||||
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||
tests[1].start()
|
||||
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||
# The first finishes while the second is still probing: it mustn't show its
|
||||
# rows or mark the second done.
|
||||
release[1].set()
|
||||
tests[0].join(10)
|
||||
self.assertFalse(tests[0].is_alive())
|
||||
running = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertFalse(running["done"])
|
||||
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||
release[2].set()
|
||||
tests[1].join(10)
|
||||
self.assertFalse(tests[1].is_alive())
|
||||
finally:
|
||||
for e in release.values():
|
||||
e.set()
|
||||
for t in tests:
|
||||
if t.ident: # started
|
||||
t.join(10)
|
||||
result = self.link.snapshot()["tests"][d["id"]]
|
||||
self.assertTrue(result["done"])
|
||||
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||
|
||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
@@ -541,7 +924,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.
|
||||
@@ -640,6 +1023,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):
|
||||
|
||||
@@ -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,161 @@
|
||||
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
|
||||
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
|
||||
staying quiet when the page goes away mid-reply, which on Windows is
|
||||
ConnectionAbortedError (WinError 10053).
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import email.message
|
||||
import io
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_host # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def platform(name):
|
||||
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
|
||||
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
|
||||
LINUX=name == "linux")
|
||||
|
||||
|
||||
class OpenRdp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.xrdp = socket.socket()
|
||||
self.xrdp.bind(("127.0.0.1", 0))
|
||||
self.xrdp.listen(4)
|
||||
self.addCleanup(self.xrdp.close)
|
||||
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
|
||||
port.start()
|
||||
self.addCleanup(port.stop)
|
||||
self.spawned = []
|
||||
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
|
||||
spawn.start()
|
||||
self.addCleanup(spawn.stop)
|
||||
cache = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(cache.cleanup)
|
||||
self.cache = Path(cache.name)
|
||||
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
|
||||
where.start()
|
||||
self.addCleanup(where.stop)
|
||||
|
||||
def test_windows_signs_in_as_steamos(self):
|
||||
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
|
||||
with platform("windows"):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(len(self.spawned), 1)
|
||||
argv = self.spawned[0]
|
||||
self.assertEqual(argv[0], "mstsc.exe")
|
||||
self.assertNotIn("/v:127.0.0.1", argv)
|
||||
rdp = Path(argv[1])
|
||||
self.assertEqual(rdp.suffix, ".rdp")
|
||||
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
|
||||
self.assertNotIn(b"\r\r", data)
|
||||
lines = data.decode("utf-8").split("\r\n")
|
||||
self.assertIn("full address:s:127.0.0.1", lines)
|
||||
self.assertIn("username:s:steamos", lines)
|
||||
self.assertIn("steamos", message)
|
||||
self.assertIn("Developer Mode password", message)
|
||||
self.assertIn("certificate", message)
|
||||
self.assertIn("Connect", message)
|
||||
|
||||
def test_nothing_listening_says_why_and_opens_nothing(self):
|
||||
self.xrdp.close()
|
||||
for name in ("windows", "mac", "linux"):
|
||||
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_says_which_way_it_failed(self):
|
||||
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||
(socket.timeout("timed out"), "didn't answer"),
|
||||
(OSError(65, "No route to host"), "didn't answer")):
|
||||
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "frame.local")
|
||||
self.assertIn(says, str(cm.exception))
|
||||
self.assertNotIn("refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_server_says_it_as_the_persons_to_fix(self):
|
||||
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||
self.xrdp.close()
|
||||
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||
self.assertRaises(server.Failure) as cm:
|
||||
server.open_thing({"what": "rdp"})
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
|
||||
def test_one_file_per_address(self):
|
||||
with platform("windows"):
|
||||
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||
self.assertEqual(len({a, b, c, d}), 4)
|
||||
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||
|
||||
def test_address_cant_add_lines_to_the_file(self):
|
||||
with platform("windows"), self.assertRaises(frame_host.HostError):
|
||||
frame_host.rdp_file("frame\r\nusername:s:root")
|
||||
self.assertEqual(list(self.cache.iterdir()), [])
|
||||
|
||||
def test_linux_clients_get_the_user(self):
|
||||
with platform("linux"), mock.patch.object(frame_host, "which",
|
||||
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
|
||||
self.assertIn("steamos", message)
|
||||
|
||||
|
||||
class PageGoneAway(unittest.TestCase):
|
||||
"""The report's server log: the page closed while index.html was being sent, and the
|
||||
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
|
||||
|
||||
def handler(self, path="/"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
|
||||
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
|
||||
h.headers = email.message.Message()
|
||||
h.headers["Host"] = "127.0.0.1:1"
|
||||
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
|
||||
h.close_connection = True
|
||||
return h
|
||||
|
||||
def test_not_a_server_error(self):
|
||||
h = self.handler()
|
||||
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
|
||||
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
|
||||
h.do_GET()
|
||||
diagnostic.assert_not_called()
|
||||
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
|
||||
|
||||
def test_server_logs_nothing(self):
|
||||
srv = server.LoopbackServer.__new__(server.LoopbackServer)
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(sys, "stderr", err):
|
||||
try:
|
||||
raise server.ClientGone()
|
||||
except server.ClientGone:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertEqual(err.getvalue(), "")
|
||||
try:
|
||||
raise RuntimeError("real")
|
||||
except RuntimeError:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertIn("RuntimeError: real", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
+180
-1
@@ -10,6 +10,7 @@ import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
@@ -111,6 +112,8 @@ class ServerGuards(unittest.TestCase):
|
||||
("/api/volume", {"level": 1.5}),
|
||||
("/api/clipboard", {"text": ""}),
|
||||
("/api/open", {"what": "anything-else"}),
|
||||
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
|
||||
("/api/open", {"what": "shot"}),
|
||||
("/api/shots/save", {"ids": []}),
|
||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||
("/api/shots/save", {"ids": [1]}),
|
||||
@@ -121,6 +124,10 @@ class ServerGuards(unittest.TestCase):
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_showing_a_shot_needs_it_saved_here(self):
|
||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
||||
self.assertEqual(status, 404, payload)
|
||||
|
||||
def test_screenshot_ids_checked_before_ssh(self):
|
||||
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
|
||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||
@@ -279,6 +286,80 @@ class OneServer(unittest.TestCase):
|
||||
r = conn.getresponse()
|
||||
self.assertEqual(r.status, 403, r.read())
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
|
||||
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
|
||||
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
|
||||
and later, now and then, crash it (SIGSEGV) while background threads were still
|
||||
loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
|
||||
for attempt in range(5):
|
||||
with self.subTest(attempt=attempt):
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
|
||||
|
||||
def test_staging_folders_cleared_when_stopped_mid_transfer(self):
|
||||
"""The server leaves without interpreter teardown, so TemporaryDirectory cleanup
|
||||
doesn't run for work still in progress: its own staging folders go on the way out,
|
||||
and a dead server's at the next start."""
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
home = tempfile.mkdtemp(prefix="frame-stop-home-")
|
||||
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
|
||||
"LOCALAPPDATA": home, "APPDATA": home}
|
||||
index_dir = Path(subprocess.run(
|
||||
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
|
||||
"print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
|
||||
env=env, capture_output=True, text=True, check=True).stdout.strip())
|
||||
index_dir.mkdir(parents=True)
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
|
||||
def staged(folder, prefix, pid):
|
||||
d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
|
||||
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
|
||||
(d / "app.apk").write_bytes(b"\0" * 4096)
|
||||
return d
|
||||
|
||||
# Stopped as the app stops it: by closing stdin (the only way on Windows,
|
||||
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
|
||||
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
|
||||
with self.subTest(stop=stop):
|
||||
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
self.assertEqual([d for d in left_by_dead if d.exists()], [])
|
||||
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
|
||||
staged(index_dir, ".download-", proc.pid)]
|
||||
if stop == "stdin":
|
||||
proc.stdin.close()
|
||||
else:
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
self.assertEqual([d for d in in_flight if d.exists()], [])
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
|
||||
|
||||
class ArtworkSettings(unittest.TestCase):
|
||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||
@@ -330,6 +411,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
|
||||
@@ -401,7 +535,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",
|
||||
}
|
||||
@@ -417,6 +555,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")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
+338
-4
@@ -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", "other", "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)
|
||||
@@ -180,6 +228,41 @@ 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")
|
||||
|
||||
|
||||
class Compat(Base):
|
||||
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
|
||||
@@ -205,6 +288,248 @@ 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 test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
|
||||
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
|
||||
# Failure; after the status poll's first connection failure, theirs are held back too,
|
||||
# also past the 10-minute window.
|
||||
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)
|
||||
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"]
|
||||
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "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 +549,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)
|
||||
@@ -391,14 +722,16 @@ class ReportProblem(Base):
|
||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||
got = self.serve()
|
||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com"})
|
||||
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com", "contactFollowup": True})
|
||||
path, body = got[0]
|
||||
event = body["batch"][0]
|
||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||
props = event["properties"]
|
||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
||||
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
|
||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
|
||||
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||
self.assertIn(res["id"], res["message"])
|
||||
@@ -421,8 +754,9 @@ class ReportProblem(Base):
|
||||
|
||||
def test_the_inbox_skips_malformed_reports(self):
|
||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||
"0.4.0", "macOS", "", ""]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
||||
"0.4.0", "macOS", "", "", None, None, None]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
||||
["short"], good]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
|
||||
+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):
|
||||
|
||||
@@ -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,162 @@
|
||||
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_host # noqa: E402
|
||||
import frame_devices as fd # noqa: E402
|
||||
|
||||
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
|
||||
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
|
||||
|
||||
|
||||
def ran(*results):
|
||||
"""subprocess.run stand-in answering whoami, then icacls."""
|
||||
calls = []
|
||||
|
||||
def run(argv, **kw):
|
||||
calls.append(argv)
|
||||
return results[len(calls) - 1]
|
||||
return run, calls
|
||||
|
||||
|
||||
class MakePrivate(unittest.TestCase):
|
||||
def test_windows_sets_owner_only_acl_by_sid(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
|
||||
subprocess.CompletedProcess([], 0))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
|
||||
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
|
||||
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
|
||||
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
|
||||
|
||||
def test_windows_falls_back_to_username_and_reports_failure(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
|
||||
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
|
||||
self.assertFalse(frame_host.make_private(Path("config")))
|
||||
self.assertIn("bob:F", calls[1])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX modes")
|
||||
def test_posix_chmods_600(self):
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
os.chmod(f.name, 0o644)
|
||||
self.assertTrue(frame_host.make_private(f.name))
|
||||
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
|
||||
|
||||
|
||||
class ConfigWrites(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
self.config = self.ssh / "config"
|
||||
|
||||
def test_devices_and_connect_writes_make_the_file_private(self):
|
||||
import frame_connect as fc
|
||||
self.config.write_text("Host other\n User me\n", encoding="utf-8")
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
|
||||
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
|
||||
fc.write_config("10.0.0.5")
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
fd.rewrite_block("frame", path=self.config, user="deck")
|
||||
self.assertEqual(private.call_count, 3)
|
||||
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
|
||||
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
|
||||
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
|
||||
|
||||
def test_setup_runs_isolated_as_the_app_starts_it(self):
|
||||
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
|
||||
self.assertNotIn("ModuleNotFoundError", r.stderr)
|
||||
self.assertIn("frame_connect.py", r.stdout + r.stderr)
|
||||
|
||||
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
|
||||
self.config.write_bytes(data)
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True):
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
self.assertEqual(self.config.read_bytes(), data)
|
||||
|
||||
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
|
||||
self.config.write_bytes(b"Host a\n")
|
||||
before = self.config.stat().st_ino
|
||||
with mock.patch.object(frame_host, "make_private", return_value=False):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
|
||||
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
|
||||
|
||||
|
||||
class ServerRepair(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
|
||||
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
|
||||
mock.patch.object(frame_host, "WINDOWS", True),
|
||||
mock.patch.object(self.server, "_config_repaired", False)]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_repairs_the_refused_config_once(self):
|
||||
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
|
||||
self.assertTrue(self.server.repair_ssh_config(REFUSED))
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_called_once()
|
||||
|
||||
def test_leaves_other_files_and_errors_alone(self):
|
||||
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
|
||||
with mock.patch.object(fd, "repair_permissions") as repair:
|
||||
self.assertFalse(self.server.repair_ssh_config(key))
|
||||
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
|
||||
with mock.patch.object(frame_host, "WINDOWS", False):
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_not_called()
|
||||
|
||||
def test_ssh_retries_after_repairing(self):
|
||||
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
|
||||
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
|
||||
mock.patch.object(fd, "repair_permissions", return_value=True), \
|
||||
mock.patch.object(self.server, "LINK", None):
|
||||
self.assertEqual(self.server.ssh("true"), "ok")
|
||||
|
||||
|
||||
class LinkLocalZone(unittest.TestCase):
|
||||
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
|
||||
|
||||
def probe(self, windows):
|
||||
import frame_link as fl
|
||||
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
|
||||
sock = mock.MagicMock()
|
||||
with mock.patch.object(frame_host, "WINDOWS", windows), \
|
||||
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
|
||||
mock.patch.object(fl.socket, "socket", return_value=sock), \
|
||||
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
|
||||
return fl.probe("frame.local", 22)["ip"]
|
||||
|
||||
def test_windows_uses_the_numeric_zone(self):
|
||||
self.assertEqual(self.probe(True), "fe80::1%12")
|
||||
|
||||
def test_elsewhere_uses_the_interface_name(self):
|
||||
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,6 +27,7 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
_refreshing = {} # source id -> background refresh thread
|
||||
@@ -530,7 +531,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:
|
||||
|
||||
+3
-1
@@ -9,6 +9,8 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
TMP_PREFIX = 'frame-agent-' # then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
|
||||
|
||||
class Approvals:
|
||||
def __init__(self):
|
||||
@@ -109,7 +111,7 @@ def call(server, body):
|
||||
if name == 'send_file':
|
||||
# Stage the reviewed bytes before the existing transfer helper reads them.
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
|
||||
source = Path(action['arguments']['path'])
|
||||
with source.open('rb') as stream:
|
||||
data = stream.read(16 * 1024**2 + 1)
|
||||
|
||||
+57
-16
@@ -11,7 +11,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
import base64, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
@@ -23,6 +23,7 @@ from frame_apk_sign import repack
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
TMP_PREFIX = 'frame-vr-' # then the server's PID, so server.sweep_tmp can clear a stopped run's patched APK
|
||||
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
|
||||
COMPAT = '.local/share/Steam/steamapps/compatdata'
|
||||
SHADERS = '.local/share/Steam/steamapps/shadercache'
|
||||
@@ -33,7 +34,8 @@ SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
|
||||
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
|
||||
XR_COMPAT_FILES = {
|
||||
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
|
||||
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
|
||||
# Gzipped in the repo and the app; see frame/openxr-compat/build.sh for why.
|
||||
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz',
|
||||
}
|
||||
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
|
||||
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
|
||||
@@ -43,16 +45,30 @@ class FrameError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LayerMissing(FrameError):
|
||||
"""The OpenXR compatibility layer's files aren't in this copy of Frame Control."""
|
||||
|
||||
|
||||
LAYER_MISSING = ("Frame Control's OpenXR compatibility layer is missing from this copy "
|
||||
"(frame/openxr-compat/prebuilt); reinstall Frame Control, or in a source "
|
||||
"checkout run frame/openxr-compat/build.sh")
|
||||
LAYER_MISSING_NOTE = ("Installed without the OpenXR compatibility layer, which is missing from this "
|
||||
"copy of Frame Control; apps that need OpenXR 1.1 may not start. Reinstalling "
|
||||
"Frame Control restores it.")
|
||||
|
||||
|
||||
def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'timed out talking to {FRAME}')
|
||||
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
|
||||
if p.returncode != 0:
|
||||
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
|
||||
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
|
||||
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
|
||||
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
|
||||
return p.stdout
|
||||
|
||||
|
||||
@@ -94,17 +110,29 @@ def xr_compat_files(apk_path):
|
||||
for entry, rel in XR_COMPAT_FILES.items():
|
||||
try:
|
||||
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
|
||||
add[entry] = f.read()
|
||||
except OSError:
|
||||
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
|
||||
data = f.read()
|
||||
add[entry] = gzip.decompress(data) if rel.endswith('.gz') else data
|
||||
except (OSError, EOFError, zlib.error): # gzip.BadGzipFile is an OSError
|
||||
add[entry] = b''
|
||||
if not add[entry]: # missing, unreadable (antivirus, permissions) or truncated
|
||||
raise LayerMissing(LAYER_MISSING)
|
||||
return add
|
||||
|
||||
|
||||
def layer_note(meta):
|
||||
"""The missing-layer warning for an install's completion message, or ''."""
|
||||
return LAYER_MISSING_NOTE if LAYER_MISSING_NOTE in ((meta or {}).get('vr_issues') or []) else ''
|
||||
|
||||
|
||||
def check_installable(info):
|
||||
if info['min_sdk'] and info['min_sdk'] > 30:
|
||||
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
|
||||
if info['abis'] and 'arm64-v8a' not in info['abis']:
|
||||
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
|
||||
# Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64,
|
||||
# universal) leave the choice to the user; say which file to fetch instead.
|
||||
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. "
|
||||
"This file is for other devices: download the APK marked arm64-v8a "
|
||||
"(or arm64, or universal) and install that instead")
|
||||
|
||||
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
@@ -120,7 +148,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
||||
else:
|
||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -159,18 +187,31 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
if flatscreen is None:
|
||||
flatscreen = not info['vr']
|
||||
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
|
||||
# changes calls SteamVR would otherwise reject.
|
||||
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
|
||||
# changes calls SteamVR would otherwise reject. Without it OpenXR 1.0 apps
|
||||
# still run, so a copy of Frame Control that lacks it installs anyway and
|
||||
# says so, unless the layer was asked for explicitly.
|
||||
add = {}
|
||||
if info['vr'] if xr_compat is None else xr_compat:
|
||||
try:
|
||||
add = xr_compat_files(apk_path)
|
||||
except LayerMissing:
|
||||
if xr_compat:
|
||||
raise
|
||||
info['vr_issues'] = list(info.get('vr_issues') or []) + [LAYER_MISSING_NOTE]
|
||||
info['xr_layer_missing'] = True
|
||||
with _install_lock:
|
||||
if add or info['repairable']:
|
||||
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
try:
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
except FrameError as e:
|
||||
raise FrameError(f'could not prepare the APK for the Frame: {e}') from e
|
||||
info['launchable'] = True
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
else:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
except FrameError as e:
|
||||
except Exception as e: # not only FrameError: every failed install is reported
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
_after_install(info, meta, None, start)
|
||||
|
||||
@@ -11,16 +11,17 @@ import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
|
||||
+12
-6
@@ -24,10 +24,15 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# The app runs this with python -I, which leaves the script's folder off sys.path.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_host # noqa: E402
|
||||
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
# $FRAME_CONTROL_SSH_DIR in tests, as frame_devices.ssh_dir(), so they never touch the real one.
|
||||
SSH_DIR = Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
|
||||
KEY = SSH_DIR / "id_ed25519_frame"
|
||||
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
|
||||
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
|
||||
@@ -330,8 +335,9 @@ def _write_config(host, port, user):
|
||||
block = config_block(host, port, user)
|
||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
tmp.chmod(0o600)
|
||||
if not frame_host.make_private(tmp):
|
||||
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
|
||||
"Frame Control repairs it when it next connects")
|
||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
||||
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||
for attempt in range(60):
|
||||
@@ -349,9 +355,9 @@ def _write_config(host, port, user):
|
||||
|
||||
def key_login_works():
|
||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def configured_user():
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- updates: occasional notices about Frame Control releases and updates
|
||||
- followup: the maintainer may ask follow-up questions, mainly about problem reports
|
||||
|
||||
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
|
||||
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
|
||||
as problem reports (frame_report.py), so only the maintainer can read them. Every change
|
||||
sends a new event under this copy's own random contact id (not the analytics id), numbered
|
||||
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
|
||||
removing the address sends a withdrawal with no address in it, and wipes the address from
|
||||
the local log of what was sent. The maintainer lists who agreed to what with
|
||||
`python3 ui/frame_report.py contacts`. Nothing here sends email.
|
||||
|
||||
A change that can't be sent (offline) waits in the state file and is retried in the
|
||||
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
|
||||
too: once it has been shown or dismissed it never comes back.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
STATE = frame_host.data_dir('contact')
|
||||
FILE = STATE / 'contact.json'
|
||||
EMAIL_MAX = 254
|
||||
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
|
||||
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
|
||||
RETRY_EVERY = 600
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
|
||||
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
|
||||
_wake = threading.Event()
|
||||
_retrier = None
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
|
||||
'prompt': 'new', 'pending': None, 'rev': 0}
|
||||
|
||||
|
||||
def load():
|
||||
with _lock:
|
||||
s = _defaults()
|
||||
try:
|
||||
with open(FILE) as f:
|
||||
saved = json.load(f)
|
||||
if isinstance(saved, dict):
|
||||
s.update({k: v for k, v in saved.items() if k in s})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _save(s):
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = FILE.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(s, indent=1))
|
||||
os.replace(tmp, FILE)
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
|
||||
|
||||
|
||||
def flag(body, key):
|
||||
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
|
||||
v = body.get(key)
|
||||
if v is not None and not isinstance(v, bool):
|
||||
raise ValueError(f'{key} must be true or false')
|
||||
return v is True
|
||||
|
||||
|
||||
def from_report(email):
|
||||
"""Follow-up questions agreed to with a problem report: the address becomes the contact
|
||||
email with that choice ticked, so it shows in Settings and is removed the same way. Update
|
||||
notices stay on only for the same address: a different one replaces the old address with
|
||||
follow-up questions only (the report form says so before sending). Returns (contact id,
|
||||
rev) for the report to carry, read together with the change itself: a later change from
|
||||
this copy has a higher rev, and the newest such change decides whether the report's
|
||||
follow-up permission still stands, whatever the clocks say."""
|
||||
with _lock:
|
||||
s = load()
|
||||
same = s['email'].lower() == email.lower()
|
||||
changed, cid, rev = _apply({'email': s['email'] if same else email,
|
||||
'updates': s['updates'] and same, 'followup': True})
|
||||
_deliver(changed)
|
||||
return cid, rev
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
|
||||
and the Frame has connected at least once (setup worked), so it never greets a new install."""
|
||||
s = load()
|
||||
set_up = bool(frame_telemetry.settings().get('frames_seen'))
|
||||
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
|
||||
|
||||
|
||||
def _event(s):
|
||||
email = s['email'] if s['updates'] or s['followup'] else ''
|
||||
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
|
||||
'followup': bool(email and s['followup']),
|
||||
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
|
||||
|
||||
|
||||
def _send_pending(block=True):
|
||||
"""Send what's waiting, including changes made while sending. True if nothing is left
|
||||
waiting. Without block, a send already under way is left to pick up the newest change."""
|
||||
if not _send_lock.acquire(blocking=block):
|
||||
return False
|
||||
try:
|
||||
while True:
|
||||
with _lock:
|
||||
event = load()['pending']
|
||||
if event is None:
|
||||
break
|
||||
try:
|
||||
frame_telemetry.post([event], timeout=30)
|
||||
except frame_telemetry.SendError:
|
||||
return False
|
||||
_sent(event)
|
||||
finally:
|
||||
_send_lock.release()
|
||||
# A change saved just as this finished found the lock still held and left it to us.
|
||||
with _lock:
|
||||
left = load()['pending'] is not None
|
||||
return _send_pending(block=False) if left else True
|
||||
|
||||
|
||||
def _sent(event):
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
|
||||
s['pending'] = None
|
||||
_save(s)
|
||||
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
|
||||
if event['properties']['email'] in ('', s['email']):
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _forget_locally(email):
|
||||
"""Take a removed address out of the log of what was sent (contact events and reports)."""
|
||||
with frame_telemetry._lock:
|
||||
_removed[email.lower()] = time.time()
|
||||
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
|
||||
hit = False
|
||||
for e in rows:
|
||||
p = e.get('properties') or {}
|
||||
for k in ('email', 'contact'):
|
||||
if p.get(k) and str(p[k]).strip().lower() == email.lower():
|
||||
p[k], hit = '<removed>', True
|
||||
if hit:
|
||||
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
|
||||
|
||||
|
||||
def redact_removed(event, started):
|
||||
"""Before logging a report (started at time.time() `started`) whose address was removed
|
||||
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
|
||||
removal can't slip between this and the log."""
|
||||
p = event.get('properties') or {}
|
||||
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
|
||||
if removed_at is not None and started <= removed_at:
|
||||
p['contact'] = '<removed>'
|
||||
|
||||
|
||||
def save(body):
|
||||
"""Set, change or remove the address and the two choices. An address needs at least one
|
||||
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
|
||||
_deliver(_apply(body)[0])
|
||||
return state()
|
||||
|
||||
|
||||
def _apply(body):
|
||||
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
|
||||
email = str(body.get('email') or '').strip()
|
||||
updates, followup = flag(body, 'updates'), flag(body, 'followup')
|
||||
if email and not valid_email(email):
|
||||
raise ValueError("that doesn't look like an email address")
|
||||
if email and not (updates or followup):
|
||||
raise ValueError('tick what the address may be used for, or remove it')
|
||||
if not email:
|
||||
updates = followup = False
|
||||
with _lock:
|
||||
s = load()
|
||||
old = s['email']
|
||||
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
|
||||
s.update(email=email, updates=updates, followup=followup)
|
||||
if body.get('fromPrompt') or email:
|
||||
s['prompt'] = 'answered'
|
||||
if changed:
|
||||
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
|
||||
# is sent even for an address still waiting here: its send may already be under way.
|
||||
s['rev'] += 1
|
||||
s['pending'] = _event(s)
|
||||
_save(s)
|
||||
if old and old.lower() != email.lower():
|
||||
try:
|
||||
_forget_locally(old)
|
||||
except OSError:
|
||||
pass
|
||||
return changed, s['id'], s['rev']
|
||||
|
||||
|
||||
def _deliver(changed):
|
||||
if changed and not _send_pending(block=False):
|
||||
_wake.set() # offline, or a send under way that will take this change with it
|
||||
|
||||
|
||||
def prompt(body):
|
||||
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
|
||||
action = body.get('prompt')
|
||||
if action not in ('shown', 'dismissed'):
|
||||
raise ValueError('unknown prompt action')
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['prompt'] in ('new', 'shown'):
|
||||
s['prompt'] = action
|
||||
_save(s)
|
||||
return state()
|
||||
|
||||
|
||||
def start():
|
||||
"""Retry a change that couldn't be sent, from now on in the background."""
|
||||
global _retrier
|
||||
if _retrier:
|
||||
return
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
_send_pending()
|
||||
except Exception:
|
||||
pass
|
||||
_wake.wait(RETRY_EVERY)
|
||||
_wake.clear()
|
||||
|
||||
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
|
||||
_retrier.start()
|
||||
+156
-22
@@ -28,8 +28,10 @@ remembered IP is caught.
|
||||
|
||||
Python stdlib only.
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -241,8 +243,7 @@ def _write_config(path, text, expected):
|
||||
try:
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
if not frame_host.WINDOWS:
|
||||
tmp.chmod(0o600)
|
||||
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
|
||||
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||
if read_config(path) != expected:
|
||||
return False
|
||||
@@ -271,6 +272,37 @@ def _edit_config(path, change):
|
||||
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
||||
|
||||
|
||||
def repair_permissions(path=None):
|
||||
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
|
||||
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
|
||||
got that ACL and replaced the file."""
|
||||
path = Path(path or ssh_config())
|
||||
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||
tmp = Path(tmp)
|
||||
try:
|
||||
with os.fdopen(fd_, "wb") as fh:
|
||||
fh.write(data)
|
||||
if not frame_host.make_private(tmp):
|
||||
return False
|
||||
for attempt in range(20): # a running ssh.exe can hold the file for a moment
|
||||
if path.read_bytes() != data:
|
||||
return False
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except PermissionError:
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
||||
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
||||
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||
@@ -333,8 +365,8 @@ def remove_block(alias, path=None):
|
||||
def effective_port(alias, config):
|
||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 22
|
||||
m = re.search(r"^port (\d+)$", out, re.M)
|
||||
@@ -346,8 +378,8 @@ def effective_port(alias, config):
|
||||
|
||||
def _keygen(*args):
|
||||
try:
|
||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10)
|
||||
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
@@ -388,19 +420,100 @@ def seed_pin(device_id, hosts, port=22, sources=None):
|
||||
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
|
||||
keys.append(f"{name} {f[1]} {f[2]}")
|
||||
if keys:
|
||||
target = known_hosts(device_id)
|
||||
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
|
||||
os.replace(tmp, target) # whole file at once: ssh never sees half of it
|
||||
_write_pin(device_id, keys)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pin_dir():
|
||||
"""Where the per-headset files go, created private: ssh won't create it itself."""
|
||||
d = ssh_dir() / PIN_DIR
|
||||
d.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _write_pin(device_id, lines):
|
||||
target = known_hosts(device_id)
|
||||
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(pin_dir()))
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(dict.fromkeys(lines)) + "\n")
|
||||
frame_host.make_private(tmp) # Windows: this user only, not the folder's inherited ACL
|
||||
os.replace(tmp, target) # whole file at once: ssh never sees half of it
|
||||
|
||||
|
||||
# The host key types OpenSSH writes to known_hosts.
|
||||
KEY_TYPE_RE = re.compile(r"ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp(?:256|384|521)|"
|
||||
r"sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com")
|
||||
|
||||
|
||||
def fingerprint(blob):
|
||||
"""OpenSSH's SHA256 fingerprint of a base64 key blob (what `ssh-keygen -l` and ssh -v print),
|
||||
or None if it isn't base64."""
|
||||
try:
|
||||
raw = base64.b64decode(blob, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")
|
||||
|
||||
|
||||
def key_entry(line, name):
|
||||
"""(type, blob, fingerprint) from a known_hosts line for `name`, or None."""
|
||||
f = (line or "").split()
|
||||
if len(f) < 3 or line.startswith("#") or name not in f[0].split(",") or not KEY_TYPE_RE.fullmatch(f[1]):
|
||||
return None
|
||||
fp = fingerprint(f[2])
|
||||
return (f[1], f[2], fp) if fp else None
|
||||
|
||||
|
||||
def pinned_keys(device_id):
|
||||
"""[(type, fingerprint)] of the keys saved for a headset (plain entries only)."""
|
||||
try:
|
||||
lines = known_hosts(device_id).read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
name = host_key_alias(device_id)
|
||||
return [(e[0], e[2]) for e in (key_entry(line, name) for line in lines) if e]
|
||||
|
||||
|
||||
def pin_key(device_id, key_type, blob):
|
||||
"""Save one key as the headset's whole identity, replacing whatever was saved: what the
|
||||
person confirmed after seeing its fingerprint (see frame_link.Link.trust_identity)."""
|
||||
if not KEY_TYPE_RE.fullmatch(key_type or "") or not fingerprint(blob):
|
||||
raise DeviceError("That isn't an SSH host key")
|
||||
_write_pin(device_id, [f"{host_key_alias(device_id)} {key_type} {blob}"])
|
||||
|
||||
|
||||
def scan_file(device_id):
|
||||
"""(path, how ssh is told) for a throwaway known_hosts file that a key check writes the
|
||||
headset's key to, without touching the saved one. `~` as in known_hosts_opt."""
|
||||
name = f".scan-{device_id}-{secrets.token_hex(4)}"
|
||||
path = pin_dir() / name
|
||||
path.touch(**({} if frame_host.WINDOWS else {"mode": 0o600}))
|
||||
frame_host.make_private(path)
|
||||
return path, (str(path) if os.environ.get("FRAME_CONTROL_SSH_DIR") else f"~/.ssh/{PIN_DIR}/{name}")
|
||||
|
||||
|
||||
def identity_established(device):
|
||||
"""Whether this headset ever had an SSH identity here: the device's own flag (set once it
|
||||
had a key or a connection that worked, and never cleared, whatever happens to its
|
||||
addresses), a fingerprint recorded in devices.json, a pin file at all (even empty,
|
||||
unreadable or not parseable), or an address that worked. Then a missing or unusable pin
|
||||
fails closed, needing Check its key; only a headset with none of these may save the
|
||||
first key it meets (accept-new)."""
|
||||
if device.get("identity_established") or device.get("identity"):
|
||||
return True
|
||||
try:
|
||||
if os.path.lexists(known_hosts(device["id"])):
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
return any(a.get("last_ok") for a in device.get("addresses") or ())
|
||||
|
||||
|
||||
def forget_pin(device_id):
|
||||
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
|
||||
trusts whatever key the headset shows, as a first connection does."""
|
||||
"""Drop a headset's saved key (the headset was removed, or the API's forget-identity).
|
||||
The next connection trusts whatever key the headset shows, as a first connection does;
|
||||
the page's Trust a new key flow (frame_link) shows the key first instead."""
|
||||
try:
|
||||
known_hosts(device_id).unlink()
|
||||
return True
|
||||
@@ -480,6 +593,10 @@ class Registry:
|
||||
for a in d["addresses"]:
|
||||
a.setdefault("networks", [])
|
||||
a.setdefault("label", "")
|
||||
if d.get("identity") or any(a.get("last_ok") for a in d["addresses"]):
|
||||
# Files from before the flag existed: it had an identity (or a connection that
|
||||
# worked, which saved one). Kept from now on, whatever happens to its addresses.
|
||||
d["identity_established"] = True
|
||||
return NAME_RE.fullmatch(d.get("id", "")) is not None
|
||||
except (KeyError, TypeError, DeviceError):
|
||||
return False
|
||||
@@ -623,9 +740,7 @@ class Registry:
|
||||
return a
|
||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||
|
||||
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||
user's order makes it win over the others that work on the same network."""
|
||||
def add_address(self, device_id, host, kind=None, label=""):
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = new_address(host, kind, label)
|
||||
@@ -633,7 +748,7 @@ class Registry:
|
||||
raise DeviceError(f"{a['host']} is already on the list")
|
||||
if len(d["addresses"]) >= 32:
|
||||
raise DeviceError("That's enough addresses for one headset")
|
||||
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||
d["addresses"].append(a)
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
@@ -684,8 +799,25 @@ class Registry:
|
||||
a["networks"] = (a["networks"] + [network_id])[-16:]
|
||||
a["last_ok"] = time.time()
|
||||
a["last_rtt_ms"] = rtt_ms
|
||||
self._find(device_id)["identity_established"] = True # survives editing the address away
|
||||
self.save()
|
||||
|
||||
def record_identity(self, device_id, fingerprints):
|
||||
"""Remember the fingerprints of the key saved for a headset, so that losing the pin
|
||||
file later can't turn into trusting whatever answers (identity_established)."""
|
||||
fingerprints = [f for f in fingerprints if isinstance(f, str) and f.startswith("SHA256:")][:8]
|
||||
if not fingerprints:
|
||||
return
|
||||
with self._changing():
|
||||
try:
|
||||
d = self._find(device_id)
|
||||
except DeviceError:
|
||||
return
|
||||
if d.get("identity") != fingerprints or not d.get("identity_established"):
|
||||
d["identity"] = fingerprints
|
||||
d["identity_established"] = True
|
||||
self.save()
|
||||
|
||||
def undismiss(self, alias):
|
||||
"""Set Up Connection is about to run for this alias: import its block again."""
|
||||
with self._changing():
|
||||
@@ -769,15 +901,17 @@ class Registry:
|
||||
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
|
||||
d["config_host"] = host
|
||||
changed = True
|
||||
if seed and host:
|
||||
seed_pin(d["id"], [host], b["port"])
|
||||
if seed and host and seed_pin(d["id"], [host], b["port"]):
|
||||
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or d.get("identity")
|
||||
d["identity_established"] = True
|
||||
elif host and host != d.get("config_host"):
|
||||
# Set Up Connection ran again and found the headset somewhere new.
|
||||
d["config_host"] = host
|
||||
if not any(a["host"] == host for a in d["addresses"]):
|
||||
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
|
||||
if seed:
|
||||
seed_pin(d["id"], [host], b["port"])
|
||||
if seed and seed_pin(d["id"], [host], b["port"]) and not d.get("identity"):
|
||||
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or None
|
||||
d["identity_established"] = True
|
||||
changed = True
|
||||
if d.get("config_login") != [user, b["port"]]:
|
||||
# Set Up Connection (or an edit) changed who to log in as, or the port.
|
||||
|
||||
+165
-8
@@ -5,12 +5,17 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MAC = sys.platform == "darwin"
|
||||
@@ -32,6 +37,68 @@ class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# The start of a line in which ssh itself says the link to the headset failed (not the command it ran).
|
||||
# That includes ssh refusing the headset's host key, and the headset refusing this computer's
|
||||
# key ("user@host: Permission denied (publickey,...)."): either way no command ran.
|
||||
SSH_LINK_FAILED = re.compile(r"^(?:ssh: connect to host |ssh: Could not resolve hostname |banner exchange: |"
|
||||
r"kex_exchange_identification: |mux_client_\w+: |client_loop: |"
|
||||
r"Connection (?:closed|reset) by \S+ port \d+|Connection timed out during banner exchange|"
|
||||
r"Host key verification failed\.|No \S+ host key is known for |"
|
||||
r"[^\n@:]{1,64}@\S+: Permission denied \()", # a Windows user name may have spaces
|
||||
re.M)
|
||||
|
||||
|
||||
def ssh_link_failed(returncode, stderr):
|
||||
"""Whether an ssh run failed to reach the headset: ssh's own exit code (255) and its own words."""
|
||||
return returncode == 255 and bool(SSH_LINK_FAILED.search(stderr or ""))
|
||||
|
||||
|
||||
def link_failure(error):
|
||||
"""Mark an exception as ssh failing to reach the headset, judged where ssh ran (so error
|
||||
reports can tell it from a message that only looks like one: a file name, say)."""
|
||||
error.frame_link_failed = True
|
||||
return error
|
||||
|
||||
|
||||
class Unreachable(HostError):
|
||||
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
||||
|
||||
|
||||
def run_ssh(argv, **kwargs):
|
||||
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||
|
||||
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
||||
while keeping subprocess.run's captured output, text, check and timeout API.
|
||||
"""
|
||||
if not WINDOWS:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
if kwargs.pop("capture_output", False):
|
||||
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
||||
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
||||
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if kwargs.get("stderr") != subprocess.PIPE:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
check = kwargs.pop("check", False)
|
||||
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
||||
with tempfile.TemporaryFile() as stderr:
|
||||
kwargs["stderr"] = stderr
|
||||
try:
|
||||
result = subprocess.run(argv, **kwargs)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
stderr.seek(0)
|
||||
error.stderr = stderr.read()
|
||||
raise
|
||||
stderr.seek(0)
|
||||
if text:
|
||||
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
||||
result.stderr = reader.read()
|
||||
else:
|
||||
result.stderr = stderr.read()
|
||||
if check:
|
||||
result.check_returncode()
|
||||
return result
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||
@@ -153,6 +220,19 @@ def open_path(path):
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
def reveal_path(path):
|
||||
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
|
||||
path = Path(path)
|
||||
if MAC:
|
||||
cmd = ["open", "-R", str(path)]
|
||||
elif WINDOWS:
|
||||
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
|
||||
else:
|
||||
return open_path(path.parent)
|
||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
open_url = open_path # the same openers hand URLs to the default browser
|
||||
|
||||
|
||||
@@ -228,10 +308,46 @@ def clipboard_text():
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
||||
BAD_PERMISSIONS = "Bad owner or permissions on "
|
||||
|
||||
|
||||
def make_private(path):
|
||||
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
|
||||
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
|
||||
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
|
||||
refuses it if that grants anyone else, even an account deleted long ago
|
||||
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
|
||||
if not WINDOWS:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
me = os.environ.get("USERNAME", "")
|
||||
try: # "desktop\me","S-1-5-21-..."
|
||||
# Bytes: the account name is in the console's code page, the SID is ASCII.
|
||||
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
|
||||
if sid.startswith("S-1-"):
|
||||
me = "*" + sid
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if not me:
|
||||
return False
|
||||
try:
|
||||
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
|
||||
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def ssh_hostname(alias):
|
||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return alias
|
||||
for line in out.splitlines():
|
||||
@@ -264,24 +380,65 @@ def open_steam_link():
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
RDP_PORT = 3389
|
||||
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
|
||||
# xrdp's certificate is its own, so every client warns about it first.
|
||||
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
||||
"with your Developer Mode password")
|
||||
|
||||
|
||||
def check_rdp(host, timeout=3):
|
||||
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
||||
try:
|
||||
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
||||
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
||||
"then restart it and try again.") from None
|
||||
except socket.gaierror:
|
||||
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
||||
"address on the Devices tab.") from None
|
||||
except OSError as e:
|
||||
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
||||
"switched off or on another network; if it's on, check Developer Mode is on "
|
||||
"in Steam Settings > System.") from None
|
||||
|
||||
|
||||
def rdp_file(host):
|
||||
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
|
||||
computer's Windows account, which xrdp turns away; the file names steamos instead."""
|
||||
if any(c in host for c in "\r\n"):
|
||||
raise HostError("That headset address can't be used for remote desktop")
|
||||
# One file per address, so two launches close together can't swap headsets.
|
||||
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
||||
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
||||
return path
|
||||
|
||||
|
||||
def open_rdp(alias, host=None):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||
host = host or ssh_hostname(alias)
|
||||
# The client would open either way and then fail on its own, with nothing said here.
|
||||
check_rdp(host)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||
return "Windows App isn't installed; opened its App Store page"
|
||||
if WINDOWS:
|
||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||
return f"Opened Remote Desktop to {host}"
|
||||
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||
# Windows asks about the unsigned connection file first.
|
||||
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||
if which("remmina"):
|
||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||
return f"Opened Remmina to {host}"
|
||||
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||
for name in ("xfreerdp3", "xfreerdp"):
|
||||
if which(name):
|
||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}"
|
||||
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||
|
||||
|
||||
|
||||
+554
-51
@@ -28,16 +28,21 @@ Python stdlib only. Runs on this computer, never on the Frame.
|
||||
import copy
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
import frame_devices
|
||||
import frame_host
|
||||
import frame_network
|
||||
import frame_telemetry
|
||||
|
||||
PROBE_TIMEOUT = 4 # seconds for a TCP answer on port 22
|
||||
RESOLVE_GRACE = 6 # ...after however long the name lookup took, up to this much
|
||||
@@ -48,6 +53,7 @@ NETWORK_EVERY = 5 # how often the network fingerprint is read
|
||||
TAILSCALE_EVERY = 30
|
||||
RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure
|
||||
REQUEST_GAP = 5 # a request may start a new attempt this long after the last one
|
||||
SCAN_TTL = 600 # a key shown for "Trust a new key" may be trusted this long after it was read
|
||||
|
||||
STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"),
|
||||
("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"),
|
||||
@@ -74,8 +80,8 @@ def ssh_g(alias):
|
||||
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
||||
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10).stdout
|
||||
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
out = ""
|
||||
got = {}
|
||||
@@ -88,6 +94,32 @@ def ssh_g(alias):
|
||||
return got.get("hostname") or alias, port, got.get("user"), proxied
|
||||
|
||||
|
||||
def proxy_route(alias):
|
||||
"""How ~/.ssh/config sends ALIAS: (None, None) directly, ("jump", spec) through
|
||||
ProxyJump, or ("command", text) through a ProxyCommand."""
|
||||
try:
|
||||
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
out = ""
|
||||
got = {}
|
||||
for line in out.splitlines():
|
||||
k, _, v = line.partition(" ")
|
||||
if k in ("proxyjump", "proxycommand") and k not in got:
|
||||
got[k] = v.strip()
|
||||
if got.get("proxyjump") not in (None, "", "none"):
|
||||
return "jump", got["proxyjump"]
|
||||
if got.get("proxycommand") not in (None, "", "none"):
|
||||
return "command", got["proxycommand"]
|
||||
return None, None
|
||||
|
||||
|
||||
# One jump host, as ProxyJump names it: [user@]host[:port], nothing that could start an
|
||||
# option, split a shell word or carry an ssh % token.
|
||||
JUMP_RE = re.compile(r"(?:([A-Za-z0-9][A-Za-z0-9._-]{0,63})@)?"
|
||||
r"(\[[0-9A-Fa-f:.]{2,45}\]|[A-Za-z0-9][A-Za-z0-9.-]{0,252})(?::(\d{1,5}))?")
|
||||
|
||||
|
||||
def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
"""Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}.
|
||||
|
||||
@@ -108,7 +140,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
ip = addr[0]
|
||||
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
|
||||
try: # a link-local IPv6 address only works with its interface
|
||||
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
|
||||
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
|
||||
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
left = deadline - now()
|
||||
@@ -135,6 +168,115 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
return last or {"state": "timeout", "detail": "No answer"}
|
||||
|
||||
|
||||
def _ip_kind(text):
|
||||
"""ipv4, ipv6, ipv6 link-local or tailscale for an IP address (zone allowed), else None."""
|
||||
try:
|
||||
ip = ipaddress.ip_address((text or "").strip("[]").split("%")[0])
|
||||
except ValueError:
|
||||
return None
|
||||
if frame_network.is_tailscale(str(ip)):
|
||||
return "tailscale"
|
||||
return "ipv4" if ip.version == 4 else "ipv6 link-local" if ip.is_link_local else "ipv6"
|
||||
|
||||
|
||||
def address_kind(host, ip=None):
|
||||
"""What sort of address a probe row is, for diagnostics, never the address itself:
|
||||
".local", "ipv4", "ipv6", "ipv6 link-local", "tailscale" or "hostname", plus what a
|
||||
name resolved to (".local->ipv6 link-local") when the probe got that far."""
|
||||
h = (host or "").lower().rstrip(".")
|
||||
kind = _ip_kind(h) or (".local" if h.endswith(".local") else
|
||||
"tailscale" if frame_network.is_tailscale(h) else "hostname")
|
||||
got = _ip_kind(ip) if ip and not _ip_kind(h) else None
|
||||
return f"{kind}->{got}" if got and got != kind else kind
|
||||
|
||||
|
||||
def probes_summary(probes):
|
||||
"""Each address tried, as its kind and how the probe went: "alias .local->ipv4 timeout"."""
|
||||
out = []
|
||||
for row in probes or ():
|
||||
lead = "alias " if row.get("label") == "from ~/.ssh/config" else ""
|
||||
out.append(f"{lead}{address_kind(row.get('host'), row.get('ip'))} {row.get('state') or '?'}")
|
||||
return "; ".join(out)
|
||||
|
||||
|
||||
# Exactly the tokens the scrubbers write (here and frame_telemetry.scrub), nothing else in <...>.
|
||||
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
|
||||
|
||||
|
||||
def hide_hosts(text, hosts, keep=()):
|
||||
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
|
||||
longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
|
||||
can't recognise goes too."""
|
||||
keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = PLACEHOLDER.split(text) # never inside a scrubber's own <token> already there
|
||||
for i in range(0, len(parts), 2):
|
||||
for h in names:
|
||||
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ssh names the host it was going to after these words ("Could not resolve hostname X",
|
||||
# "connect to host X port 22", "Timed out talking to X"): whatever follows goes, known or not.
|
||||
OPERAND = re.compile(r"(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'\"()|]+)")
|
||||
PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect", "find", "work", "try"}
|
||||
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
|
||||
|
||||
|
||||
def hide_operands(text):
|
||||
def one(m):
|
||||
word = m.group(2).rstrip(".")
|
||||
dots = m.group(2)[len(word):]
|
||||
return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} <host>{dots}"
|
||||
return OPERAND.sub(one, text)
|
||||
|
||||
|
||||
def scrub_failure(text, hosts=()):
|
||||
"""Free text about a failed attempt, for the log: the attempt's own names, whole and
|
||||
longest first (case-insensitively, never ssh's own words), then anything ssh names as a
|
||||
host, then the shared scrubber (addresses, paths, user names)."""
|
||||
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
|
||||
|
||||
|
||||
def failure_category(message, raw=""):
|
||||
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
|
||||
word, never its text."""
|
||||
return frame_telemetry.categorize(f"{message or ''}\n{raw or ''}")[0]
|
||||
|
||||
|
||||
_logged = {} # failure line -> {"at": when last written, "repeats": since then}
|
||||
LOG_REPEAT_EVERY = 300 # the same failure, retried every 30 s, goes in the log at most this often
|
||||
LOG_REMEMBER = 32 # different failures remembered for that
|
||||
|
||||
|
||||
def log_failure(stage, message, raw="", probes=(), hosts=()):
|
||||
"""One failed connection attempt to stderr (the app's server.log), scrubbed when written,
|
||||
with the hosts of that attempt (so a later switch of headset can't let them through)."""
|
||||
hosts = list(hosts) + [r.get(k) for r in probes or () for k in ("host", "ip")]
|
||||
bits = [f"frame_link: {stage} failed: {scrub_failure(message, hosts)}"]
|
||||
last = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()][-1:]
|
||||
if last and last[0] != message:
|
||||
bits.append(f"ssh said: {scrub_failure(last[0][:300], hosts)}")
|
||||
tried = probes_summary(probes)
|
||||
if tried:
|
||||
bits.append(f"addresses: {tried}")
|
||||
line = " | ".join(bits)[:900]
|
||||
t = time.monotonic()
|
||||
seen = _logged.get(line)
|
||||
if seen and t - seen["at"] < LOG_REPEAT_EVERY:
|
||||
seen["repeats"] += 1
|
||||
return
|
||||
more = f" (and {seen['repeats']} more times)" if seen and seen["repeats"] else ""
|
||||
_logged.pop(line, None)
|
||||
_logged[line] = {"at": t, "repeats": 0}
|
||||
while len(_logged) > LOG_REMEMBER:
|
||||
_logged.pop(next(iter(_logged))) # the least recently written
|
||||
try:
|
||||
print(line + more, file=sys.stderr, flush=True)
|
||||
except (OSError, ValueError, AttributeError):
|
||||
pass # no stderr (a closed pipe): the page still shows the failure
|
||||
|
||||
|
||||
def ssh_target(host, ip):
|
||||
"""Where ssh should go for an address whose probe answered from `ip`: that IP, so ssh
|
||||
doesn't look the name up again and try an address that didn't answer."""
|
||||
@@ -145,6 +287,43 @@ def ssh_target(host, ip):
|
||||
return host
|
||||
|
||||
|
||||
def _unlink(path):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _scanned_key(path, name):
|
||||
"""The first whole, valid known_hosts line for `name` that a key check wrote, or None.
|
||||
Only lines ssh has finished (ending in a newline): a half-written key mustn't count."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
for line in text.splitlines(True):
|
||||
if line.endswith("\n"):
|
||||
entry = frame_devices.key_entry(line.strip(), name)
|
||||
if entry:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def proxy_command(literal):
|
||||
"""A ProxyCommand that runs `literal` (argv, ending with the jump host) with `-W` to the
|
||||
headset, each word exactly as given. ssh first expands its %-tokens in the line, so every
|
||||
% of ours is doubled; then it hands the line to a shell (`$SHELL -c` on macOS and Linux)
|
||||
or, on Windows, starts it as a command line, so every word is quoted for that: a path
|
||||
with spaces, $(...), backticks or quotes stays one plain word. The forward is
|
||||
[%h]:%p, so an IPv6 address (with a zone) reads as one host."""
|
||||
words = [w.replace("%", "%%") for w in literal]
|
||||
target = "[%h]:%p"
|
||||
if frame_host.WINDOWS:
|
||||
return subprocess.list2cmdline([*words[:-1], "-W", target, words[-1]])
|
||||
return " ".join(shlex.quote(w) for w in [*words[:-1], "-W", target, words[-1]])
|
||||
|
||||
|
||||
def probe_raw(host, port, result):
|
||||
"""ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
|
||||
return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
|
||||
@@ -163,13 +342,13 @@ class Link:
|
||||
self.session_alias = env_alias # ...and stays selectable after switching away
|
||||
self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...]
|
||||
self.control = control # ControlPath, or None where ssh can't share connections
|
||||
self.apply = apply # apply(alias, host_opts): point every ssh command at the headset
|
||||
self._apply = apply # apply(alias, host_opts): point every ssh command at the headset
|
||||
self.applied = (None, []) # what it was last given
|
||||
self.explain = explain # ssh error text -> plain reason, or None
|
||||
self.cond = threading.Condition()
|
||||
self.version = 0
|
||||
self.stopped = False
|
||||
self.kicks = [] # reasons someone asked for a (re)connect
|
||||
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||
self.busy = False # the loop is handling kicks
|
||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||
@@ -188,6 +367,9 @@ class Link:
|
||||
self.route_lock = threading.Lock()
|
||||
self.routed = None # the device id every ssh command points at
|
||||
self.routed_device = None
|
||||
self.last_failure = None # {"stage", "category", "at"}: for report diagnostics, no free text
|
||||
self.attempt_device = None # the headset the current attempt is for
|
||||
self.scans = {} # device id -> the host key it showed for "Trust a new key"
|
||||
|
||||
# ---- publishing ----
|
||||
def publish(self, **fields):
|
||||
@@ -392,13 +574,76 @@ class Link:
|
||||
return [*mux, *(device.get("frozen") or [])] # what ~/.ssh/config said when it was routed
|
||||
if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
|
||||
return ["-o", "HostName=no-address.invalid"]
|
||||
# StrictHostKeyChecking=yes: whatever ~/.ssh/config says for Host *, every command
|
||||
# checks the headset's pinned key (only the connector's first handshake may save one).
|
||||
return [*mux, "-o", "StrictHostKeyChecking=yes", "-o", f"HostName={frame_devices.ssh_host(host)}",
|
||||
# Whatever ~/.ssh/config says for Host *, every command checks the headset's pinned key:
|
||||
# StrictHostKeyChecking=yes. Only a headset that never had an identity here (just
|
||||
# added, never connected) gets accept-new: the first contact saves its key, as Set Up
|
||||
# Connection and the connector's first handshake do, rather than every command failing
|
||||
# with "No host key is known" until the connector gets there. Once it had one, a pin
|
||||
# file that's gone, empty or unreadable fails closed (identity_established): accept-new
|
||||
# can't tell that from a new headset and would trust whatever answers.
|
||||
strict = self.strictness(device)
|
||||
if strict == "accept-new":
|
||||
try:
|
||||
frame_devices.pin_dir() # ssh won't create the folder its known_hosts file goes in
|
||||
except OSError:
|
||||
pass # ssh then says it couldn't save the key, and goes on
|
||||
return [*mux, "-o", f"StrictHostKeyChecking={strict}", "-o", f"HostName={frame_devices.ssh_host(host)}",
|
||||
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
|
||||
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
|
||||
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
|
||||
|
||||
def apply(self, alias, opts):
|
||||
self.applied = (alias, list(opts))
|
||||
self._apply(alias, opts)
|
||||
|
||||
def strictness(self, device):
|
||||
"""yes, or accept-new for a headset that never had an identity. Judged on what the
|
||||
registry says now, not only the copy handed in (a route frozen at connect, say, from
|
||||
before its first key was recorded). A key saved since is recorded on the way."""
|
||||
try:
|
||||
current = self.reg.get(device["id"])
|
||||
except frame_devices.DeviceError:
|
||||
current = {}
|
||||
if not (frame_devices.identity_established(device) or
|
||||
(current and frame_devices.identity_established(current))):
|
||||
return "accept-new"
|
||||
self.settle_pin(dict(device, **current) if current else device, tighten=False)
|
||||
return "yes"
|
||||
|
||||
def settle_pin(self, device=None, tighten=True):
|
||||
"""A key ssh saved on a first contact, whatever happened next (logged in or refused,
|
||||
by the handshake or any command): record it, make the file private, and from then on
|
||||
route every command strictly. Idempotent and cheap when there's nothing to do."""
|
||||
device = device or self.routed_device
|
||||
if not device or device.get("transient") or device.get("none") or not device.get("id"):
|
||||
return
|
||||
did = device["id"]
|
||||
if frame_devices.pinned(did):
|
||||
fps = [f for _, f in frame_devices.pinned_keys(did)]
|
||||
if fps and (device.get("identity") != fps or not device.get("identity_established")):
|
||||
try:
|
||||
current = self.reg.get(did)
|
||||
except frame_devices.DeviceError:
|
||||
return
|
||||
if current.get("identity") != fps or not current.get("identity_established"):
|
||||
frame_host.make_private(frame_devices.known_hosts(did)) # ssh wrote it
|
||||
self.reg.record_identity(did, fps)
|
||||
if not tighten:
|
||||
return
|
||||
with self.route_lock:
|
||||
alias, opts = self.applied
|
||||
if alias == device.get("alias") and "StrictHostKeyChecking=accept-new" in opts and \
|
||||
frame_devices.identity_established(dict(device, **self._current(did))):
|
||||
self.opts = [("StrictHostKeyChecking=yes" if o == "StrictHostKeyChecking=accept-new" else o)
|
||||
for o in opts]
|
||||
self.apply(alias, self.opts)
|
||||
|
||||
def _current(self, device_id):
|
||||
try:
|
||||
return self.reg.get(device_id)
|
||||
except frame_devices.DeviceError:
|
||||
return {}
|
||||
|
||||
def public_device(self, d):
|
||||
return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
|
||||
|
||||
@@ -428,6 +673,8 @@ class Link:
|
||||
if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons:
|
||||
last_ts = t
|
||||
self.refresh_network()
|
||||
if "StrictHostKeyChecking=accept-new" in self.applied[1]:
|
||||
self.settle_pin() # a command's first contact may have saved the key
|
||||
phase = self.state["phase"]
|
||||
if phase == "connected" and not self.alive():
|
||||
reasons.append("dropped")
|
||||
@@ -436,7 +683,9 @@ class Link:
|
||||
if reasons:
|
||||
self.connect(reasons)
|
||||
except Exception as e: # keep the loop alive whatever happens; say what went wrong
|
||||
self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
|
||||
message = f"{type(e).__name__}: {e}"
|
||||
self.note_failure("network", message, str(e))
|
||||
self.publish(phase="failed", error={"stage": "network", "message": message,
|
||||
"raw": str(e)}, retry_at=now() + RETRY[-1])
|
||||
finally:
|
||||
with self.cond:
|
||||
@@ -512,6 +761,7 @@ class Link:
|
||||
self.cond.notify_all()
|
||||
ok = False
|
||||
try:
|
||||
self.attempt_device = device # its names, for scrubbing this attempt's log lines
|
||||
ok = self.attempt(device)
|
||||
finally:
|
||||
self.finish(gen, ok, device)
|
||||
@@ -540,6 +790,7 @@ class Link:
|
||||
now() + RETRY[min(self.fails, len(RETRY)) - 1])
|
||||
if not self.state["error"]:
|
||||
self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
|
||||
self.note_failure("find", "Couldn't connect", "", self.state["probes"])
|
||||
self.version += 1
|
||||
self.cond.notify_all()
|
||||
|
||||
@@ -558,17 +809,31 @@ class Link:
|
||||
return "Starting up"
|
||||
return "Connecting"
|
||||
|
||||
def fail(self, sid, message, raw=""):
|
||||
def fail(self, sid, message, raw="", **more):
|
||||
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth)."""
|
||||
self.stage(sid, "failed", message)
|
||||
with self.cond:
|
||||
self.state["error"] = {"stage": sid, "message": message, "raw": raw}
|
||||
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
|
||||
probes = copy.deepcopy(self.state["probes"])
|
||||
self.note_failure(sid, message, raw, probes)
|
||||
|
||||
def note_failure(self, stage, message, raw="", probes=()):
|
||||
"""Keep a failure for report diagnostics as fixed values only (its stage and error
|
||||
category, decided now), and write it to the log scrubbed with this attempt's hosts."""
|
||||
self.last_failure = {"stage": stage, "category": failure_category(message, raw), "at": now()}
|
||||
hosts = []
|
||||
for d in (self.attempt_device, self.routed_device): # the headset tried, and where commands go
|
||||
d = d or {}
|
||||
hosts += [a.get("host") for a in d.get("addresses") or ()]
|
||||
hosts += [d.get("frozen_host"), d.get("alias"), d.get("name")]
|
||||
log_failure(stage, message, raw, probes, hosts)
|
||||
|
||||
def attempt(self, device):
|
||||
if device.get("none"):
|
||||
self.fail("find", "No headset is set up. Add one on the Devices tab.")
|
||||
return False
|
||||
if not device.get("transient") and not device["addresses"]:
|
||||
self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.")
|
||||
self.fail("find", "The active headset has no addresses. Add one on the Devices tab.")
|
||||
return False
|
||||
# 1. this computer's network
|
||||
self.stage("network", "active")
|
||||
@@ -737,6 +1002,7 @@ class Link:
|
||||
if device.get("transient"):
|
||||
return
|
||||
self.reg.record_success(device["id"], host, net.get("id"), rtt)
|
||||
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
|
||||
# Set Up Connection may have changed the block while this attempt ran: take that
|
||||
# in (and reconnect) rather than writing this attempt's older settings over it.
|
||||
self.watch_config()
|
||||
@@ -766,8 +1032,8 @@ class Link:
|
||||
if not self.control:
|
||||
return False
|
||||
try:
|
||||
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
@@ -778,8 +1044,8 @@ class Link:
|
||||
pending.kill()
|
||||
if self.control and self.alias:
|
||||
try:
|
||||
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5)
|
||||
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if proc and proc.poll() is None:
|
||||
@@ -791,7 +1057,17 @@ class Link:
|
||||
|
||||
def handshake(self, device, a, found, user):
|
||||
"""SSH to one address, following ssh -v through stages 3-5.
|
||||
-> "ok", "next" (try another address) or "stop"."""
|
||||
-> "ok", "next" (try another address) or "stop". A key ssh saved is recorded and
|
||||
protected however it ends: a login refused after the key was saved, a timeout, a stop."""
|
||||
try:
|
||||
return self._handshake(device, a, found, user)
|
||||
finally:
|
||||
if not device.get("transient"):
|
||||
with self.route_lock:
|
||||
current = self.attempt_gen == self.gen
|
||||
self.settle_pin(device, tighten=current)
|
||||
|
||||
def _handshake(self, device, a, found, user):
|
||||
# The IP that answered (with a link-local IPv6 address's zone), so ssh doesn't
|
||||
# look the name up again and stall on an address that didn't answer.
|
||||
opts = self.host_opts(device, ssh_target(a["host"], found.get("ip")))
|
||||
@@ -809,15 +1085,9 @@ class Link:
|
||||
return "ok"
|
||||
extra = []
|
||||
if not device.get("transient"):
|
||||
if frame_devices.pinned(device["id"]):
|
||||
extra = ["-o", "StrictHostKeyChecking=yes"]
|
||||
else:
|
||||
# First connection since this headset was added: trust what it shows
|
||||
# (as Set Up Connection does), and pin it from now on. ssh won't create
|
||||
# the folder its known_hosts file goes in.
|
||||
extra = ["-o", "StrictHostKeyChecking=accept-new"]
|
||||
pins = frame_devices.known_hosts(device["id"]).parent
|
||||
pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||
# A headset's first connection trusts what it shows (as Set Up Connection does) and
|
||||
# pins it; any later one checks the pin strictly (see host_opts).
|
||||
extra = ["-o", f"StrictHostKeyChecking={self.strictness(device)}"]
|
||||
if self.control:
|
||||
# No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
# `extra` first: ssh takes the first value of an option, and it may say accept-new.
|
||||
@@ -825,27 +1095,70 @@ class Link:
|
||||
"-o", "ServerAliveCountMax=2", "-N", alias]
|
||||
else:
|
||||
argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ConnectTimeout=10", alias, "true"]
|
||||
lines = queue.Queue()
|
||||
collecting = [True]
|
||||
reader_done = threading.Event()
|
||||
errfile = None
|
||||
try:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, **frame_host.DETACHED)
|
||||
if self.control:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, **frame_host.DETACHED)
|
||||
else:
|
||||
# No master (Windows): ssh runs `true` and exits. Its stderr goes to a file read
|
||||
# as it grows, not a pipe: Windows' OpenSSH can block writing to a redirected
|
||||
# pipe (see frame_host.run_ssh), and the handshake would never finish.
|
||||
fd_, errfile = tempfile.mkstemp(prefix="frame-ssh-")
|
||||
try:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=fd_, **frame_host.DETACHED)
|
||||
finally:
|
||||
os.close(fd_)
|
||||
except OSError as e:
|
||||
if errfile:
|
||||
_unlink(errfile)
|
||||
self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
|
||||
return "stop"
|
||||
self.pending = proc # so stop() can end it mid-handshake
|
||||
lines = queue.Queue()
|
||||
collecting = [True]
|
||||
|
||||
def read():
|
||||
for raw in iter(proc.stderr.readline, b""):
|
||||
if collecting[0]:
|
||||
lines.put(raw.decode("utf-8", "replace").rstrip())
|
||||
reader_done.set()
|
||||
lines.put(None)
|
||||
proc.stderr.close()
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
|
||||
def tail():
|
||||
buf = b""
|
||||
try:
|
||||
with open(errfile, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read()
|
||||
if not chunk:
|
||||
if proc.poll() is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
chunk = fh.read() # whatever it wrote just before exiting
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
*done, buf = buf.split(b"\n")
|
||||
for raw in done:
|
||||
lines.put(raw.decode("utf-8", "replace").rstrip())
|
||||
except OSError:
|
||||
pass
|
||||
if buf:
|
||||
lines.put(buf.decode("utf-8", "replace").rstrip())
|
||||
reader_done.set()
|
||||
lines.put(None)
|
||||
_unlink(errfile)
|
||||
threading.Thread(target=read if errfile is None else tail, daemon=True).start()
|
||||
|
||||
step, said, authed = "ssh", [], False
|
||||
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
|
||||
mismatch = False
|
||||
shown = None # (type, fingerprint) of the key the headset showed
|
||||
exited = None # when ssh was first seen to have exited
|
||||
while True:
|
||||
left = deadline - time.monotonic()
|
||||
if self.stopped:
|
||||
@@ -863,7 +1176,14 @@ class Link:
|
||||
if authed and self.control and self.check(opts, alias):
|
||||
break
|
||||
if proc.poll() is not None and lines.empty():
|
||||
line = None
|
||||
# ssh exited: its last words (Permission denied…) may still be on their way.
|
||||
# The reader's end marker says when they're all in; a pipe that something
|
||||
# else holds open (a ProxyCommand's child) gets a short grace instead.
|
||||
exited = exited or time.monotonic()
|
||||
if reader_done.is_set() and lines.empty() or time.monotonic() - exited > 2:
|
||||
line = None
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
if line is None: # ssh exited
|
||||
@@ -872,7 +1192,7 @@ class Link:
|
||||
break
|
||||
if authed and self.control and self.check(opts, alias):
|
||||
break
|
||||
return self.failed(step, said, mismatch, alias)
|
||||
return self.failed(step, said, mismatch, alias, shown, a["host"], device)
|
||||
if not line.startswith("debug"):
|
||||
said.append(line)
|
||||
m = CONNECTING.search(line)
|
||||
@@ -885,12 +1205,15 @@ class Link:
|
||||
self.stage("identity", "active", "Waiting for the headset's host key")
|
||||
elif HOSTKEY.search(line):
|
||||
m = HOSTKEY.search(line)
|
||||
shown = (m.group(1), m.group(2))
|
||||
self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…")
|
||||
elif KNOWN.search(line):
|
||||
self.stage("identity", "done", "Matches the identity saved for this headset")
|
||||
step = "login"
|
||||
self.stage("login", "active", f"Logging in as {user}")
|
||||
elif ADDED.search(line):
|
||||
if not device.get("transient"):
|
||||
self.settle_pin(device, tighten=False) # now, before the login can fail
|
||||
self.stage("identity", "done", "First connection: saved this headset's identity")
|
||||
step = "login"
|
||||
self.stage("login", "active", f"Logging in as {user}")
|
||||
@@ -929,18 +1252,34 @@ class Link:
|
||||
# OpenSSH lower-cases host names (FRAME.LOCAL logs as frame.local).
|
||||
return not m or m.group(1).lower() in (host.lower(), alias.lower()) or "Authentication succeeded" in line
|
||||
|
||||
def failed(self, step, said, mismatch, alias):
|
||||
def failed(self, step, said, mismatch, alias, shown=None, host=None, device=None):
|
||||
text = "\n".join(said).strip()
|
||||
if mismatch:
|
||||
self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). "
|
||||
"If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text)
|
||||
# A key that differs from the saved one, or (strict, pinned) none of the saved key's
|
||||
# type any more: either way not the identity saved. Never accepted here: the page
|
||||
# shows the new key's fingerprint and the person decides (Trust a new key).
|
||||
name = (device or {}).get("name") or alias
|
||||
lost = bool(device) and not frame_devices.pinned(device["id"]) and \
|
||||
frame_devices.identity_established(device)
|
||||
if lost:
|
||||
message = (f"The SSH identity saved for {name} is missing or can't be read, so Frame Control won't "
|
||||
"trust whatever answers. Check its key and trust it on the Devices tab.")
|
||||
else:
|
||||
message = (f"This address answered with a different SSH identity from the one saved for {name}. "
|
||||
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
|
||||
"it on the Devices tab. If not, a different device may be answering here.")
|
||||
self.fail("identity", message, text,
|
||||
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
|
||||
"fingerprint": shown[1] if shown else None, "host": host})
|
||||
return "next"
|
||||
# A refused key is the same at every address: stop. (Judged by ssh's own words, not
|
||||
# the step: a jump host's progress lines look like the headset's.) A forward that
|
||||
# a jump host couldn't open is about this address only: try the next.
|
||||
forward = re.search(r"open failed|forwarding failed|Connection refused|Connection closed|timed out", text)
|
||||
if re.search(r"Permission denied", text) and not forward:
|
||||
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text)
|
||||
# Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
|
||||
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
|
||||
"Connection: it pairs this computer with the headset again.", text, auth="denied")
|
||||
return "stop"
|
||||
if step == "connected":
|
||||
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
|
||||
@@ -956,13 +1295,9 @@ class Link:
|
||||
started = now()
|
||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||
with self.cond:
|
||||
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||
|
||||
def put(**fields):
|
||||
with self.cond:
|
||||
if gen != self.test_gen[device_id]:
|
||||
return # a newer test has started: its results are the ones to show
|
||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||
self.version += 1
|
||||
self.cond.notify_all()
|
||||
@@ -984,12 +1319,14 @@ class Link:
|
||||
lead = f"Answered in {res['rtt_ms']:g} ms" if res.get("rtt_ms") is not None else "Through the jump host"
|
||||
rows[i]["ssh"] = "checking"
|
||||
put()
|
||||
# "yes" before host_opts (ssh takes an option's first value): a test only looks, so
|
||||
# it reports a key not saved yet rather than saving it as host_opts' accept-new would.
|
||||
argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
|
||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
||||
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))), device["alias"], "true"]
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=20)
|
||||
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=20)
|
||||
err = r.stderr.strip()
|
||||
if r.returncode == 0:
|
||||
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
||||
@@ -1014,6 +1351,156 @@ class Link:
|
||||
put(done=True, finished=now())
|
||||
self.devices_changed()
|
||||
|
||||
# ---- Trust a new key ----
|
||||
# Every way a key check could do more than read the key: no login of any kind, no agent
|
||||
# (nor forwarding it), no X11, port or tunnel forwarding, no local or remote command, no
|
||||
# terminal, no shared connection, no learning keys. Given first, so ssh takes these over
|
||||
# anything ~/.ssh/config (or host_opts) says. -N: no command, even if a device accepts "none".
|
||||
SCAN_OPTS = ["BatchMode=yes", "ControlMaster=no", "ControlPath=none", "ConnectTimeout=8",
|
||||
"StrictHostKeyChecking=accept-new", "UpdateHostKeys=no", "HashKnownHosts=no",
|
||||
"PreferredAuthentications=none", "PubkeyAuthentication=no", "PasswordAuthentication=no",
|
||||
"KbdInteractiveAuthentication=no", "HostbasedAuthentication=no", "IdentityAgent=none",
|
||||
"ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no", "ClearAllForwardings=yes",
|
||||
"Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none", "RequestTTY=no"]
|
||||
SCAN_TIMEOUT = 20
|
||||
|
||||
def scan_identity(self, device_id):
|
||||
"""Read the headset's host key without trusting it or logging in. ssh writes it to a
|
||||
throwaway known_hosts file during the key exchange, before any authentication, and is
|
||||
stopped as soon as it has (or when it gives up); SCAN_OPTS and -N rule out anything else.
|
||||
`-F none` too, unless ~/.ssh/config sends the alias through a jump host (then it's
|
||||
needed for that, and SCAN_OPTS still win). The address that showed the changed key goes
|
||||
first, then the rest in order. -> {"type", "fingerprint", "host", "saved", "recorded",
|
||||
"matches", "recorded_match"}; the key is kept (SCAN_TTL) for trust_identity, which pins
|
||||
exactly it."""
|
||||
device = self.reg.get(device_id)
|
||||
if not device["addresses"]:
|
||||
raise frame_devices.DeviceError("This headset has no addresses to ask")
|
||||
hosts = [a["host"] for a in device["addresses"]]
|
||||
with self.cond:
|
||||
err = self.state.get("error") or {}
|
||||
mine = (self.state.get("device") or {}).get("id") == device_id
|
||||
first = (err.get("hostkey") or {}).get("host") if mine else None
|
||||
if first in hosts:
|
||||
hosts.remove(first)
|
||||
hosts.insert(0, first)
|
||||
route = self.scan_route(device)
|
||||
reasons = []
|
||||
for host in hosts:
|
||||
key, err_text = self._scan_one(device, host, route)
|
||||
if key:
|
||||
saved = frame_devices.pinned_keys(device_id)
|
||||
recorded = [f for f in device.get("identity") or [] if isinstance(f, str)]
|
||||
scan = {"type": key[0], "blob": key[1], "fingerprint": key[2], "host": host,
|
||||
"port": device["port"], "user": device["user"], "at": now()}
|
||||
with self.cond:
|
||||
self.scans[device_id] = scan
|
||||
return {"type": key[0], "fingerprint": key[2], "host": host,
|
||||
"saved": [{"type": t, "fingerprint": f} for t, f in saved], "recorded": recorded,
|
||||
"matches": any(f == key[2] for _, f in saved), "recorded_match": key[2] in recorded}
|
||||
text = err_text.strip()
|
||||
reasons.append(f"{host}: " + (self.explain(text) or (text.splitlines() or ["no answer"])[-1]))
|
||||
raise frame_devices.DeviceError("Couldn't read the headset's key. " + "; ".join(reasons))
|
||||
|
||||
# The jump host's own ssh (a ProxyCommand the scan builds itself): it may log in to the jump
|
||||
# host as configured (keys, the agent), but it never forwards the agent, X11 or ports, runs
|
||||
# no local or remote command, has no terminal, shares no connection and goes no further.
|
||||
HOP_OPTS = ["BatchMode=yes", "ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no",
|
||||
"ClearAllForwardings=yes", "Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none",
|
||||
"RequestTTY=no", "ControlMaster=no", "ControlPath=none", "ProxyCommand=none", "ProxyJump=none"]
|
||||
|
||||
def scan_route(self, device):
|
||||
"""The options that take a key check to the headset: `-F none` and nothing else when
|
||||
it's reached directly. Through a jump host (ProxyJump), `-F none` and a ProxyCommand
|
||||
built here, whose ssh gets HOP_OPTS ahead of ~/.ssh/config: config options can't be
|
||||
overridden for ProxyJump's own ssh, which inherits ForwardAgent, LocalCommand and the
|
||||
rest. Through a ProxyCommand from ~/.ssh/config, or several jump hosts, it refuses:
|
||||
those can't be made safe from here."""
|
||||
kind, spec = proxy_route(device["alias"])
|
||||
if kind is None:
|
||||
return ["-F", "none"]
|
||||
m = JUMP_RE.fullmatch(spec or "") if kind == "jump" else None
|
||||
if not m or (m.group(3) and not 1 <= int(m.group(3)) <= 65535):
|
||||
how = "a ProxyCommand" if kind == "command" else "this jump host setting"
|
||||
raise frame_devices.DeviceError(
|
||||
f"{device['name']} is reached through {how} in ~/.ssh/config, which Frame Control can't make safe "
|
||||
"for talking to a device it doesn't trust yet, so it won't read the key that way. Add an address "
|
||||
"it can reach directly (or through a single ProxyJump host) and check its key again; or compare "
|
||||
"the key by hand (on the headset, in Konsole: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)")
|
||||
user, host, port = m.groups()
|
||||
# The ssh this server runs, by its full path: the shell that runs a ProxyCommand may set
|
||||
# up another PATH.
|
||||
ssh = frame_host.which(self.mux_base[0])
|
||||
if not ssh:
|
||||
raise frame_devices.DeviceError(f"Couldn't find {self.mux_base[0]} to reach the jump host with")
|
||||
hop = [ssh, *(a for o in self.HOP_OPTS for a in ("-o", o)), *(["-l", user] if user else []),
|
||||
*(["-p", port] if port else []), host.strip("[]")] # ssh takes a bare IPv6 address here
|
||||
return ["-F", "none", "-o", "ProxyCommand=" + proxy_command(hop)]
|
||||
|
||||
def _scan_one(self, device, host, route):
|
||||
"""-> ((type, blob, fingerprint) or None, what ssh said)."""
|
||||
name = frame_devices.host_key_alias(device["id"])
|
||||
path, opt = frame_devices.scan_file(device["id"])
|
||||
opts = [a for o in [*self.SCAN_OPTS, f"UserKnownHostsFile={opt}"] for a in ("-o", o)]
|
||||
argv = [self.mux_base[0], *route, *opts, *self.host_opts(device, host), "-N", device["alias"]]
|
||||
fd_, errfile = tempfile.mkstemp(prefix="frame-scan-") # a file, not a pipe (see frame_host.run_ssh)
|
||||
key, proc = None, None
|
||||
try:
|
||||
try:
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=fd_,
|
||||
**frame_host.DETACHED)
|
||||
finally:
|
||||
os.close(fd_)
|
||||
deadline = time.monotonic() + self.SCAN_TIMEOUT
|
||||
while key is None:
|
||||
ended = proc.poll() is not None
|
||||
key = _scanned_key(path, name)
|
||||
if key or ended or self.stopped or time.monotonic() > deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
except OSError as e:
|
||||
return None, str(e)
|
||||
finally:
|
||||
if proc and proc.poll() is None:
|
||||
proc.kill() # it has the key (or gave up): nothing more to do with that device
|
||||
if proc:
|
||||
try:
|
||||
proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
with open(errfile, "rb") as fh:
|
||||
said = fh.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
said = ""
|
||||
_unlink(errfile)
|
||||
_unlink(path)
|
||||
if key is None and not said.strip() and proc and proc.returncode is not None and proc.returncode < 0:
|
||||
said = f"Timed out talking to {device['alias']}"
|
||||
return key, said
|
||||
|
||||
def trust_identity(self, device_id, fingerprint):
|
||||
"""Pin the key scan_identity read, and only if `fingerprint` is the one it showed (what
|
||||
the person saw and confirmed), replacing whatever was saved; and only while the headset
|
||||
is still reached as it was then (same port and user, the address still one of its own).
|
||||
-> the scan."""
|
||||
with self.cond:
|
||||
scan = self.scans.get(device_id)
|
||||
if not scan or now() - scan["at"] > SCAN_TTL:
|
||||
self.scans.pop(device_id, None)
|
||||
raise frame_devices.DeviceError("Check the headset's key again first: the one shown has expired")
|
||||
if not isinstance(fingerprint, str) or fingerprint != scan["fingerprint"]:
|
||||
raise frame_devices.DeviceError("That isn't the key the headset showed. Check its key again")
|
||||
self.scans.pop(device_id)
|
||||
d = self.reg.get(device_id) # still a headset here
|
||||
if (d["port"], d["user"]) != (scan["port"], scan["user"]) or \
|
||||
scan["host"] not in [a["host"] for a in d["addresses"]]:
|
||||
raise frame_devices.DeviceError("The headset's address, port or user changed since its key was read. "
|
||||
"Check its key again")
|
||||
frame_devices.pin_key(device_id, scan["type"], scan["blob"])
|
||||
self.reg.record_identity(device_id, [scan["fingerprint"]])
|
||||
return scan
|
||||
|
||||
|
||||
# ---- the page's API: /api/devices -------------------------------------------------
|
||||
|
||||
@@ -1032,6 +1519,9 @@ def devices_view(link):
|
||||
view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")}
|
||||
view["active"] = d["id"] == active["id"]
|
||||
view["pinned"] = frame_devices.pinned(d["id"])
|
||||
view["keys"] = [{"type": t, "fingerprint": f} for t, f in frame_devices.pinned_keys(d["id"])]
|
||||
# It had an identity, but the saved key is gone or unusable: nothing is trusted until checked.
|
||||
view["identity_lost"] = not view["pinned"] and frame_devices.identity_established(d)
|
||||
view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
|
||||
for a in d["addresses"]]
|
||||
devices.append(view)
|
||||
@@ -1064,7 +1554,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
is_active = did == active["id"]
|
||||
moves = action == "use" or (action == "retry" and link.alive()) or (is_active and (
|
||||
# (a retry while connected would cut the install's connection)
|
||||
action in ("remove", "address-remove", "forget-identity")
|
||||
action in ("remove", "address-remove", "identity-trust")
|
||||
or (action == "update" and login_change(reg, did, body))
|
||||
or (action == "address-update" and body.get("newHost") not in (None, body.get("host")))))
|
||||
if moves and busy():
|
||||
@@ -1116,8 +1606,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||
elif action == "address-add":
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||
first=body.get("first") is True)
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||
if is_active and link.state["phase"] == "failed":
|
||||
link.kick("retry")
|
||||
msg = f"Added {a['host']}"
|
||||
@@ -1145,11 +1634,25 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
threading.Thread(target=link.test, args=(did,), daemon=True).start()
|
||||
msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
|
||||
elif action == "forget-identity":
|
||||
# Dropping the pin used to let the next contact save whatever key answered, unseen.
|
||||
# A new key is now always shown first: identity-scan, then identity-trust.
|
||||
reg.get(did)
|
||||
raise frame_devices.DeviceError("Forgetting a headset's identity is no longer supported: use Check its key "
|
||||
"on the Devices tab (identity-scan, then identity-trust), which shows the "
|
||||
"new key before saving it")
|
||||
elif action == "identity-scan":
|
||||
d = reg.get(did)
|
||||
frame_devices.forget_pin(did)
|
||||
scan = link.scan_identity(did)
|
||||
msg = (f"{d['name']} shows the key already saved for it" if scan["matches"]
|
||||
else f"{d['name']} shows a {scan['type']} key, {scan['fingerprint']}")
|
||||
link.devices_changed()
|
||||
return dict(devices_view(link), message=msg, scan=scan)
|
||||
elif action == "identity-trust":
|
||||
d = reg.get(did)
|
||||
link.trust_identity(did, body.get("fingerprint"))
|
||||
if is_active:
|
||||
link.kick("switch")
|
||||
msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
|
||||
link.kick("switch") # reconnect with the key now saved
|
||||
msg = f"Saved {d['name']}'s new SSH identity; reconnecting"
|
||||
elif action == "name-network":
|
||||
reg.name_network(body.get("network"), body.get("name"))
|
||||
if link.state["network"]:
|
||||
|
||||
+288
-13
@@ -6,14 +6,25 @@ project as a `problem_report` event: only the maintainer can read it, and
|
||||
nothing is published. It is sent whatever the analytics settings are, because
|
||||
the person sends it deliberately. Diagnostics are scrubbed first
|
||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
||||
|
||||
An email address goes with a report only when the person ticks "may contact me with
|
||||
follow-up questions" (contact_followup). Standing choices made in Settings are
|
||||
frame_contact.py's `contact_consent` events; `contacts` lists them.
|
||||
"""
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_contact
|
||||
import frame_devices
|
||||
import frame_host
|
||||
import frame_link
|
||||
import frame_telemetry
|
||||
|
||||
KINDS = ('bug', 'idea', 'question', 'other')
|
||||
@@ -23,6 +34,7 @@ LOG_LINES = 60
|
||||
ACTIVITY_LINES = 25
|
||||
|
||||
frame = {} # the Frame's last known SteamOS build, set by server.status()
|
||||
link = None # the connector (frame_link.Link), set by server.main; None on the Frame itself
|
||||
|
||||
|
||||
def u16(s):
|
||||
@@ -54,9 +66,174 @@ def _log_tail():
|
||||
return list(reversed(keep[-LOG_LINES:]))
|
||||
|
||||
|
||||
def ssh_path_kind(path):
|
||||
"""What sort of ssh a path is, never the path itself (it can hold a user name)."""
|
||||
if not path:
|
||||
return "not found"
|
||||
p = str(path).replace("\\", "/").lower()
|
||||
for marks, kind in ((("/system32/openssh/", "/sysnative/openssh/"), "Windows OpenSSH (System32)"),
|
||||
(("/program files/openssh",), "OpenSSH in Program Files"),
|
||||
(("/git/",), "Git for Windows"), (("msys", "cygwin"), "MSYS2/Cygwin"),
|
||||
(("/opt/homebrew/", "/usr/local/", "linuxbrew"), "Homebrew or /usr/local"),
|
||||
(("/nix/",), "Nix")):
|
||||
if any(m in p for m in marks):
|
||||
return kind
|
||||
if p in ("/usr/bin/ssh", "/bin/ssh"):
|
||||
return "system OpenSSH"
|
||||
return "other"
|
||||
|
||||
|
||||
def _ssh_on_path():
|
||||
"""Every ssh on PATH, in the order they're found; the first is the one the app runs."""
|
||||
names = ("ssh.exe", "ssh") if frame_host.WINDOWS else ("ssh",)
|
||||
found, seen = [], set()
|
||||
for d in os.get_exec_path():
|
||||
for name in names:
|
||||
cand = os.path.join(d, name)
|
||||
key = os.path.normcase(os.path.abspath(cand))
|
||||
if key not in seen and os.path.isfile(cand):
|
||||
seen.add(key)
|
||||
found.append(cand)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
# Only a real banner at the very start ("OpenSSH_9.9p1, LibreSSL 3.3.6",
|
||||
# "OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2"): rebuilt from fixed names and its numbers.
|
||||
BANNER_RE = re.compile(r"OpenSSH_(for_Windows_)?(\d+)\.(\d+)(p\d+)?(?=[,\s]|$)")
|
||||
LIBRARY_RE = re.compile(r",?\s*(LibreSSL|OpenSSL) (\d+\.\d+\.\d+[a-z]?)(?=[,\s]|$)")
|
||||
|
||||
|
||||
def parse_ssh_version(said):
|
||||
""""OpenSSH 9.9p1, LibreSSL 3.3.6"-style text from `ssh -V`'s output, or "unknown"."""
|
||||
said = (said or "").lstrip()
|
||||
m = BANNER_RE.match(said)
|
||||
if not m:
|
||||
return "unknown"
|
||||
out = f"OpenSSH{' for Windows' if m.group(1) else ''} {m.group(2)}.{m.group(3)}{m.group(4) or ''}"
|
||||
lib = LIBRARY_RE.match(said, m.end())
|
||||
return out + (f", {lib.group(1)} {lib.group(2)}" if lib else "")
|
||||
|
||||
|
||||
def ssh_version(path):
|
||||
"""The version from `ssh -V` (it prints to stderr), nothing else it says."""
|
||||
try:
|
||||
r = frame_host.run_ssh([path, "-V"], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=5)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return f"couldn't run it ({type(e).__name__})"
|
||||
return parse_ssh_version(r.stderr or r.stdout)
|
||||
|
||||
|
||||
def _ssh_check():
|
||||
path = shutil.which("ssh")
|
||||
if not path:
|
||||
return "SSH: no ssh on PATH"
|
||||
others = [ssh_path_kind(p) for p in _ssh_on_path()
|
||||
if os.path.normcase(os.path.abspath(p)) != os.path.normcase(os.path.abspath(path))]
|
||||
others = [k for i, k in enumerate(others) if k not in others[:i]]
|
||||
return f"SSH: {ssh_path_kind(path)}, {ssh_version(path)}" + (f"; also on PATH: {', '.join(others)}" if others else "")
|
||||
|
||||
|
||||
_ssh = {"thread": None, "line": None}
|
||||
_ssh_lock = threading.Lock()
|
||||
SSH_WAIT = 0.3 # how long a report preview waits for the ssh check (PATH can hold slow network drives)
|
||||
|
||||
|
||||
def start_ssh_check():
|
||||
"""Look for ssh once, in the background (server.main starts it), so a report never waits
|
||||
on PATH folders on slow or mapped drives."""
|
||||
def run():
|
||||
try:
|
||||
line = _ssh_check()
|
||||
except Exception as e: # a report must still go out
|
||||
line = f"SSH: couldn't check ({type(e).__name__})"
|
||||
_ssh["line"] = line
|
||||
with _ssh_lock:
|
||||
if _ssh["thread"] is None:
|
||||
_ssh["thread"] = threading.Thread(target=run, name="report-ssh-check", daemon=True)
|
||||
_ssh["thread"].start()
|
||||
return _ssh["thread"]
|
||||
|
||||
|
||||
def ssh_line():
|
||||
start_ssh_check().join(SSH_WAIT)
|
||||
return _ssh["line"] or "SSH: still being checked"
|
||||
|
||||
|
||||
def config_line(alias):
|
||||
"""Whether ~/.ssh/config has the managed block for the alias, and a hand-written Host for it."""
|
||||
try:
|
||||
text = frame_devices.ssh_config().read_text(encoding="utf-8", errors="replace")
|
||||
except FileNotFoundError:
|
||||
return "~/.ssh/config: missing"
|
||||
except OSError as e:
|
||||
return f"~/.ssh/config: can't read it ({type(e).__name__})"
|
||||
blocks = [b["alias"] for b in frame_devices.parse_blocks(text)]
|
||||
outside, inside = [], None
|
||||
for line in text.splitlines():
|
||||
m = frame_devices.BLOCK_RE.fullmatch(line.strip())
|
||||
if m:
|
||||
inside = m.group(1)
|
||||
elif inside and line.strip() == frame_devices.end_mark(inside):
|
||||
inside = None
|
||||
elif not inside:
|
||||
outside.append(line)
|
||||
own = any(alias in re.split(r"[\s=]+", ln.strip())[1:] for ln in outside
|
||||
if re.match(r"(?i)\s*host[\s=]", ln))
|
||||
return (f"~/.ssh/config: managed block for the active alias {'yes' if alias in blocks else 'no'} "
|
||||
f"({len(blocks)} managed in all); hand-written Host for it {'yes' if own else 'no'}")
|
||||
|
||||
|
||||
def alias_kind(alias):
|
||||
"""`default ("frame")` or `custom`: a name someone chose can say who they are."""
|
||||
return 'default ("frame")' if alias == "frame" else "custom"
|
||||
|
||||
|
||||
def connection_lines():
|
||||
"""A short summary of the connector in fixed words only: states, stages, error categories,
|
||||
kinds of address, counts. No text from errors or ssh, no custom alias, never an address."""
|
||||
if link is None:
|
||||
return ["Connection: no connector (this server doesn't reach a headset over SSH)"]
|
||||
snap = link.snapshot()
|
||||
active = link.active_device()
|
||||
alias = active.get("alias") or "?"
|
||||
phase, err, via = snap.get("phase") or "idle", snap.get("error"), snap.get("via")
|
||||
head = f"Connection: {phase}"
|
||||
if phase == "connected" and via:
|
||||
rtt = via.get("rtt_ms")
|
||||
head += f" via {frame_link.address_kind(via.get('host'), via.get('ip'))}" + (f" ({rtt:g} ms)" if rtt is not None else "")
|
||||
elif err:
|
||||
head += f" at {err.get('stage')}"
|
||||
head += f", attempt {snap.get('attempt') or 0}" + (f" ({snap['reason']})" if snap.get("reason") else "")
|
||||
lines = [head]
|
||||
kind = ("none set up" if active.get("none") else "a bare ssh alias" if active.get("transient")
|
||||
else f"saved, {len(active.get('addresses') or [])} address(es)")
|
||||
lines.append(f"Headsets: {len(link.reg.devices())} saved; active alias {alias_kind(alias)} ({kind})")
|
||||
if err:
|
||||
lines.append(f"Error: {err.get('stage')}, {frame_link.failure_category(err.get('message'), err.get('raw'))}")
|
||||
last = link.last_failure
|
||||
if last:
|
||||
ago = max(0, int(frame_link.now() - last.get("at", 0)))
|
||||
lines.append(f"Last failure: {last.get('stage')}, {last.get('category')}, {ago // 60} min {ago % 60} s ago")
|
||||
lines.append(f"Addresses tried: {frame_link.probes_summary(snap.get('probes')) or 'none yet'}")
|
||||
net = snap.get("network")
|
||||
if net:
|
||||
ts = net.get("tailscale") or {}
|
||||
lines.append(f"Network: gateway {'yes' if net.get('gateway') else 'no'}, Tailscale "
|
||||
f"{'on' if ts.get('up') else 'off' if ts.get('installed') else 'not installed'}")
|
||||
for part in (ssh_line, lambda: config_line(alias)):
|
||||
try:
|
||||
lines.append(part())
|
||||
except Exception as e: # a report must still go out
|
||||
lines.append(f"({type(e).__name__} while checking SSH)")
|
||||
return lines
|
||||
|
||||
|
||||
def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
|
||||
"""What a report includes, scrubbed and at most `limit` UTF-16 units. Always the versions
|
||||
and builds; recent activity and the server log only when asked for, since they can name
|
||||
and builds, and a connection summary (connection_lines: kinds of address and states, never
|
||||
the addresses, so "the app can't find the headset" can be told apart); recent activity and the server log only when asked for, since they can name
|
||||
files. Sections are filled in order of use, newest lines first, so trimming drops the oldest."""
|
||||
t = frame_telemetry.state()
|
||||
levels = ', '.join(f"{name} {'on' if on else 'off'}" for name, on in
|
||||
@@ -69,7 +246,11 @@ def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
|
||||
f"Analytics: {levels}",
|
||||
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
|
||||
]
|
||||
out = frame_telemetry.scrub('\n'.join(env), limit=limit)
|
||||
try:
|
||||
conn = connection_lines()
|
||||
except Exception as e: # never stop a report over its diagnostics
|
||||
conn = [f"Connection: summary unavailable ({type(e).__name__})"]
|
||||
out = frame_telemetry.scrub('\n'.join(env) + '\n\n' + '\n'.join(conn), limit=limit)
|
||||
if not include_logs:
|
||||
return cut(out, limit)
|
||||
sections = [('Recent activity (newest first):', [str(a)[:300] for a in list(activity)[:ACTIVITY_LINES] if isinstance(a, str)]),
|
||||
@@ -107,9 +288,18 @@ def send(body):
|
||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||
title, text, diag = compose(body)
|
||||
followup = frame_contact.flag(body, 'contactFollowup')
|
||||
contact = str(body.get('contact') or '').strip() if followup else ''
|
||||
if followup and not frame_contact.valid_email(contact):
|
||||
raise ValueError('add your email address for follow-up questions, or untick that box')
|
||||
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
|
||||
# It becomes the contact email in Settings, where it's changed or removed like any other.
|
||||
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
|
||||
ref = uuid.uuid4().hex[:8].upper()
|
||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
|
||||
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
|
||||
# Only with an address: a later change from this copy (higher rev) can take it back.
|
||||
'contact_id': contact_id, 'contact_rev': contact_rev,
|
||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
||||
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
|
||||
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||
@@ -119,7 +309,9 @@ def send(body):
|
||||
except frame_telemetry.SendError as e:
|
||||
raise ReportError(str(e))
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
with frame_telemetry._lock: # the lock a removal holds while wiping its address
|
||||
frame_contact.redact_removed(event, started)
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
|
||||
@@ -131,26 +323,109 @@ class ReportError(RuntimeError):
|
||||
|
||||
def inbox(days=30):
|
||||
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
||||
frame_compat_db.sync uses)."""
|
||||
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
|
||||
questions now: 'withdrawn' when a later choice from the same copy took it back."""
|
||||
import frame_compat_db
|
||||
days = int(days)
|
||||
res = frame_compat_db._posthog_query(
|
||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
|
||||
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
|
||||
"ORDER BY timestamp DESC LIMIT 200")
|
||||
return res.get('results') or []
|
||||
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
||||
if any(r[11] and _yes(r[10]) for r in rows):
|
||||
later = frame_compat_db._posthog_query(
|
||||
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
|
||||
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
|
||||
mark_withdrawn(rows, later.get('results') or [])
|
||||
return rows
|
||||
|
||||
|
||||
def mark_withdrawn(reports, consents):
|
||||
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
|
||||
the same copy made after the report (a higher rev than it carries, not a later clock) no
|
||||
longer agrees to follow-up questions at that address."""
|
||||
newest = {}
|
||||
for c in consents:
|
||||
if not isinstance(c, list) or len(c) != 4:
|
||||
continue
|
||||
cid, email, followup, rev = c
|
||||
try:
|
||||
rev = int(rev or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if rev > newest.get(str(cid), (-1,))[0]:
|
||||
newest[str(cid)] = (rev, str(email or ''), followup)
|
||||
for r in reports:
|
||||
if not (r[11] and _yes(r[10])):
|
||||
continue
|
||||
try:
|
||||
sent_at = int(r[12] or 0)
|
||||
except (TypeError, ValueError):
|
||||
sent_at = 0
|
||||
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
|
||||
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
|
||||
r[10] = 'withdrawn'
|
||||
|
||||
|
||||
def _yes(v):
|
||||
return v is True or str(v).lower() in ('true', '1')
|
||||
|
||||
|
||||
def contacts():
|
||||
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
|
||||
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
|
||||
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
|
||||
the highest rev from that copy (then time), so every field comes from the same event
|
||||
whatever order they arrived in or what the clocks said."""
|
||||
import frame_compat_db
|
||||
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
|
||||
res = frame_compat_db._posthog_query(
|
||||
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
|
||||
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
|
||||
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
|
||||
out = {'updates': [], 'followup': []}
|
||||
for row in res.get('results') or []:
|
||||
if not isinstance(row, list) or len(row) != 5:
|
||||
continue
|
||||
_, email, updates, followup, ts = row
|
||||
email = str(email or '').strip()
|
||||
if not frame_contact.valid_email(email):
|
||||
continue
|
||||
for kind, agreed in (('updates', updates), ('followup', followup)):
|
||||
if _yes(agreed):
|
||||
out[kind].append((email, str(ts or '')[:10]))
|
||||
return out
|
||||
|
||||
|
||||
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['inbox']
|
||||
if cmd == 'contacts':
|
||||
kinds = args[:1] or ['updates', 'followup']
|
||||
if not set(kinds) <= {'updates', 'followup'}:
|
||||
sys.exit(USAGE)
|
||||
found = contacts()
|
||||
for kind in kinds:
|
||||
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
|
||||
f" ({len(found[kind])})")
|
||||
for email, since in found[kind]:
|
||||
print(f" {email} (since {since})")
|
||||
print()
|
||||
return
|
||||
if cmd != 'inbox':
|
||||
sys.exit('usage: frame_report.py inbox [days]')
|
||||
sys.exit(USAGE)
|
||||
for row in inbox(*(args[:1] or [30])):
|
||||
if not isinstance(row, list) or len(row) != 10:
|
||||
continue
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||
reply = contact and (row[10] is None or _yes(row[10]))
|
||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
|
||||
f"{', may follow up at ' + contact if reply else ''}"
|
||||
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
|
||||
print(' ' + text.replace('\n', '\n '))
|
||||
if diag:
|
||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||
|
||||
+60
-10
@@ -53,6 +53,14 @@ SENT_KEEP = 200
|
||||
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
|
||||
FLUSH_EVERY = 60
|
||||
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
|
||||
# Not faults in Frame Control: the headset asleep, away or not set up yet, or ssh and the headset
|
||||
# not trusting each other (a host key that changed, a key not authorised): the page says what to do
|
||||
# about each (Devices tab, Set Up Connection). The status poll
|
||||
# meets these every few seconds, so each is sent at most once per session, whatever the wording,
|
||||
# but only for an error marked where ssh ran as ssh failing to reach the headset
|
||||
# (frame_host.link_failure). The message alone isn't evidence: a download's "Connection reset by
|
||||
# peer", or a file name with ssh's words in it, keeps the usual window.
|
||||
EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
@@ -229,9 +237,16 @@ def scrub(text, limit=2000):
|
||||
home = str(Path.home())
|
||||
if len(home) > 3:
|
||||
t = t.replace(home, '~')
|
||||
t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1<user>', t)
|
||||
# A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
|
||||
t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
|
||||
# ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
|
||||
# spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
|
||||
# would otherwise take only the last word of the user.
|
||||
t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
|
||||
'<user>@<host>', t)
|
||||
for pattern, repl in SCRUBS:
|
||||
t = pattern.sub(repl, t)
|
||||
t = re.sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
|
||||
t = IPV6_RE.sub(_ipv6, t)
|
||||
for name in _user_names():
|
||||
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
@@ -240,16 +255,30 @@ def scrub(text, limit=2000):
|
||||
|
||||
# From the most to the least specific; the first match wins.
|
||||
CATEGORIES = [
|
||||
# A web-link download (frame_webinstall) that broke or didn't check out: not the headset,
|
||||
# whatever the reason, and first so a file name in the message can't put it elsewhere.
|
||||
('download_failed', re.compile(r"\A(?:download failed: |download cut off at |downloaded \d+ bytes; |"
|
||||
r"the server says \d+ bytes; )|doesn't match the manifest's sha256; "
|
||||
r"not installing it\Z")),
|
||||
('android_installer', re.compile(r'INSTALL_(?:FAILED|PARSE_FAILED)_[A-Z_]+')),
|
||||
('apk_needs_newer_android', re.compile(r'needs Android API')),
|
||||
('apk_wrong_abi', re.compile(r'no arm64-v8a build')),
|
||||
('layer_missing', re.compile(r'OpenXR compatibility layer')),
|
||||
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
|
||||
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
|
||||
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
|
||||
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
|
||||
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
|
||||
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
|
||||
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed')),
|
||||
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
|
||||
r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
|
||||
# Anything ssh says about its own connection: "ssh: connect to host … port 22: <reason>" (Windows
|
||||
# says "Unknown error"), a dropped shared connection (mux_client_…, client_loop), and Windows'
|
||||
# "banner exchange: Connection to UNKNOWN port -1" (its ssh can't name a peer whose connect
|
||||
# failed late). Not a bare "ssh exited 255": a command on the Frame can exit 255 too.
|
||||
('frame_unreachable', re.compile(r'(?i)timed out|Connection (?:refused|reset|closed)|No route to host|'
|
||||
r'Network is unreachable|Operation timed out|asleep|kex_exchange')),
|
||||
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
|
||||
r'ssh: connect to host |mux_client_|client_loop: ')),
|
||||
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
|
||||
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
|
||||
('flatpak', re.compile(r'(?i)flatpak|flathub')),
|
||||
@@ -349,8 +378,9 @@ def frame_seen(build, version):
|
||||
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
|
||||
|
||||
|
||||
def install_finished(kind, ok, seconds=None, error=None, **props):
|
||||
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names)."""
|
||||
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
|
||||
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names).
|
||||
diagnose=False when the error is reported as a diagnostic elsewhere."""
|
||||
p = {'kind': kind, 'ok': bool(ok), **{k: v for k, v in props.items() if v is not None}}
|
||||
if seconds is not None:
|
||||
p['seconds'] = round(seconds, 1)
|
||||
@@ -358,21 +388,41 @@ def install_finished(kind, ok, seconds=None, error=None, **props):
|
||||
p['error_category'], code = categorize(error)
|
||||
if code:
|
||||
p['installer_code'] = code
|
||||
elif not ok:
|
||||
p['error_category'] = 'other'
|
||||
capture('install_finished', p)
|
||||
if error is not None and not ok:
|
||||
if error is not None and not ok and diagnose:
|
||||
diagnostic(f'{kind} install failed', error)
|
||||
|
||||
|
||||
def link_failed(error):
|
||||
"""Whether an ssh helper marked this error (frame_host.link_failure) as ssh failing to reach the
|
||||
headset: the error itself, or one it was re-raised from (`raise Failure(...) from e`)."""
|
||||
for _ in range(10): # a cause chain is short; never loop on a cycle
|
||||
if error is None:
|
||||
return False
|
||||
if getattr(error, 'frame_link_failed', False):
|
||||
return True
|
||||
error = getattr(error, '__cause__', None)
|
||||
return False
|
||||
|
||||
|
||||
def diagnostic(where, error, tb=None):
|
||||
"""An error for the opt-in diagnostics level: scrubbed text, and a traceback if there is one."""
|
||||
if not enabled('diagnostics'):
|
||||
return
|
||||
message = scrub(error)
|
||||
fingerprint = f'{where}|{message[:120]}'
|
||||
category = categorize(error)[0]
|
||||
now = time.time()
|
||||
with _lock:
|
||||
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
|
||||
return
|
||||
if category in EXPECTED_CATEGORIES and link_failed(error):
|
||||
fingerprint = f'expected|{category}'
|
||||
if fingerprint in _seen_errors:
|
||||
return
|
||||
else:
|
||||
fingerprint = f'{where}|{message[:120]}'
|
||||
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
|
||||
return
|
||||
_seen_errors[fingerprint] = now
|
||||
exc_type = type(error).__name__ if isinstance(error, BaseException) else 'Error'
|
||||
frames = []
|
||||
@@ -385,7 +435,7 @@ def diagnostic(where, error, tb=None):
|
||||
'mechanism': {'handled': True, 'type': 'generic'},
|
||||
'stacktrace': {'type': 'raw', 'frames': frames[-30:]}}],
|
||||
'$exception_type': exc_type, '$exception_message': message,
|
||||
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
|
||||
'where': scrub(where, 200), 'error_category': category},
|
||||
level='diagnostics')
|
||||
|
||||
|
||||
|
||||
+172
-35
@@ -25,7 +25,9 @@ Standard library only (ctypes for libei), like the rest of what runs on the Fram
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -53,14 +55,41 @@ def say(state, **more):
|
||||
|
||||
# ---- which panel has focus -----------------------------------------------------
|
||||
|
||||
def xprop_root(display, name):
|
||||
PROPS = ("GAMESCOPE_FOCUSABLE_WINDOWS", "GAMESCOPE_FOCUSED_WINDOW", "GAMESCOPE_FOCUS_DISPLAY")
|
||||
TOOL_TIMEOUT = 5.0 # seconds for any one xprop or xwininfo
|
||||
PANELS_DEADLINE = 12.0 # seconds for a whole `panels` run: well inside the server's 20 s for /api/panels
|
||||
DEADLINE = [None] # when the current `panels` run must stop asking X tools (monotonic), if it has one
|
||||
|
||||
|
||||
class OutOfTime(Exception):
|
||||
"""The current run's deadline passed: stop and report what was found."""
|
||||
|
||||
|
||||
def budget(limit=TOOL_TIMEOUT):
|
||||
"""Seconds an X tool may take now: its own limit, cut to what's left of the run's deadline."""
|
||||
if DEADLINE[0] is None:
|
||||
return limit
|
||||
left = DEADLINE[0] - time.monotonic()
|
||||
if left <= 0:
|
||||
raise OutOfTime()
|
||||
return min(limit, left)
|
||||
|
||||
|
||||
def read_props(display, limit=TOOL_TIMEOUT):
|
||||
"""gamescope's root properties on one display, in one xprop call: {name: [values]} for
|
||||
those the display has, {} if none (or xprop didn't answer)."""
|
||||
try:
|
||||
out = subprocess.run(["xprop", "-root", name], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
out = subprocess.run(["xprop", "-root", *PROPS], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=budget(limit)).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
values = out.split("=", 1)[1] if "=" in out else ""
|
||||
return [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
|
||||
return {}
|
||||
props = {}
|
||||
for line in out.splitlines():
|
||||
name, sep, values = line.partition("=")
|
||||
name = name.split("(", 1)[0].strip()
|
||||
if sep and name in PROPS:
|
||||
props[name] = [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
|
||||
return props
|
||||
|
||||
|
||||
def window_info(display, window):
|
||||
@@ -68,7 +97,7 @@ def window_info(display, window):
|
||||
try:
|
||||
which = ["-root"] if window == "root" else ["-id", str(window)]
|
||||
out = subprocess.run(["xwininfo", *which], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
capture_output=True, text=True, timeout=budget()).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if "IsViewable" not in out:
|
||||
@@ -86,13 +115,58 @@ def window_info(display, window):
|
||||
|
||||
|
||||
def displays():
|
||||
return sorted(f":{n[1:]}" for n in os.listdir("/tmp/.X11-unix") if n[1:].isdigit())
|
||||
"""Every X display on the Frame: the sockets in /tmp/.X11-unix, and the abstract ones
|
||||
X servers on Linux usually listen on as well (in /proc/net/unix), in case a SteamOS
|
||||
build stops sharing /tmp/.X11-unix with SSH sessions."""
|
||||
found = set()
|
||||
try:
|
||||
found.update(int(n[1:]) for n in os.listdir("/tmp/.X11-unix") if n[:1] == "X" and n[1:].isdigit())
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with open("/proc/net/unix") as f:
|
||||
found.update(int(n) for n in re.findall(r"@/tmp/\.X11-unix/X(\d+)", f.read()))
|
||||
except OSError:
|
||||
pass
|
||||
return [f":{n}" for n in sorted(found)]
|
||||
|
||||
|
||||
# The input helper (`frame_touch.py` reading events) lives for a whole Control session and
|
||||
# reads focus often, so it remembers which display has gamescope's properties: found, for
|
||||
# good; not found, for ROOT_RETRY. A `panels` run is a fresh process each time, so this
|
||||
# saves it nothing; it costs nothing either while :0 has the properties.
|
||||
_ROOT = [] # [display, when it was given up on, or None if found]
|
||||
ROOT_PROBE_TIMEOUT = 2.0 # seconds for each display other than :0, so a stalled one can't use the run
|
||||
ROOT_RETRY = 30.0
|
||||
|
||||
|
||||
def gamescope():
|
||||
"""(display, properties): the X display whose root window carries gamescope's panel
|
||||
properties (GAMESCOPE_FOCUSABLE_WINDOWS and friends), and what they say now.
|
||||
|
||||
That's :0 on every build seen so far (SteamOS 0.3.0 to 0.4.1), so :0 is read first and,
|
||||
when it has them, used with no other probe. Only if it hasn't are the other displays
|
||||
asked, briefly each; with none, :0 and no properties.
|
||||
"""
|
||||
if _ROOT and (_ROOT[1] is None or time.monotonic() - _ROOT[1] < ROOT_RETRY):
|
||||
return _ROOT[0], read_props(_ROOT[0])
|
||||
props, found = read_props(":0"), ":0"
|
||||
if not props:
|
||||
found = None
|
||||
for display in displays():
|
||||
if display != ":0":
|
||||
props = read_props(display, ROOT_PROBE_TIMEOUT)
|
||||
if props:
|
||||
found = display
|
||||
break
|
||||
_ROOT[:] = [found, None] if found else [":0", time.monotonic()]
|
||||
return _ROOT[0], props
|
||||
|
||||
|
||||
def window_pid(display, window):
|
||||
try:
|
||||
out = subprocess.run(["xprop", "-id", str(window), "_NET_WM_PID"], env=dict(os.environ, DISPLAY=display),
|
||||
capture_output=True, text=True, timeout=5).stdout
|
||||
capture_output=True, text=True, timeout=budget()).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
value = out.rsplit("=", 1)[-1].strip() if "=" in out else ""
|
||||
@@ -120,20 +194,20 @@ def locate(window, pid):
|
||||
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
|
||||
|
||||
|
||||
def focusable():
|
||||
"""gamescope's focusable windows as (window, app id, pid)."""
|
||||
t = xprop_root(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")
|
||||
def focusable(props=None):
|
||||
"""gamescope's focusable windows as (window, app id, pid), from props if already read."""
|
||||
t = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUSABLE_WINDOWS", [])
|
||||
return [tuple(t[i:i + 3]) for i in range(0, len(t) - 2, 3)]
|
||||
|
||||
|
||||
def focus_display():
|
||||
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on :0's root.
|
||||
def focus_display(props=None):
|
||||
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on gamescope's root.
|
||||
|
||||
gamescope writes the name (":1") as 32-bit items, so its first four bytes land,
|
||||
little-endian, in the first value: 12602 is 0x313A, ":1" (steamcompmgr.cpp;
|
||||
seen 2026-09-29).
|
||||
"""
|
||||
values = xprop_root(":0", "GAMESCOPE_FOCUS_DISPLAY")
|
||||
values = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUS_DISPLAY")
|
||||
if not values:
|
||||
return None
|
||||
name = (values[0] & 0xFFFFFFFF).to_bytes(4, "little").split(b"\0", 1)[0].decode("ascii", "replace")
|
||||
@@ -141,19 +215,23 @@ def focus_display():
|
||||
|
||||
|
||||
def focus_now():
|
||||
"""Just which window and display have focus: two property reads, for checking a press."""
|
||||
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
return (window or None, focus_display() if window else None)
|
||||
"""Just which window and display have focus: one property read, for checking a press."""
|
||||
props = gamescope()[1]
|
||||
window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
return (window or None, focus_display(props) if window else None)
|
||||
|
||||
|
||||
def focus():
|
||||
def focus(props=None):
|
||||
"""The panel that has focus in the headset: window, display, name and sizes (gamescope
|
||||
publishes the window and its display on :0's root)."""
|
||||
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
publishes the window and its display on its root display, :0 so far). props: gamescope's
|
||||
properties if already read."""
|
||||
if props is None:
|
||||
props = gamescope()[1]
|
||||
window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
|
||||
if not window:
|
||||
return {"window": None}
|
||||
app, pid = next(((a, p) for w, a, p in focusable() if w == window), (None, None))
|
||||
display = focus_display()
|
||||
app, pid = next(((a, p) for w, a, p in focusable(props) if w == window), (None, None))
|
||||
display = focus_display(props)
|
||||
info = window_info(display, window) if display else None
|
||||
if info:
|
||||
root = window_info(display, "root") or {}
|
||||
@@ -164,18 +242,77 @@ def focus():
|
||||
return {**panel, "app": app} if panel else {"window": None}
|
||||
|
||||
|
||||
def panels():
|
||||
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus."""
|
||||
now = focus()
|
||||
found = []
|
||||
for window, app, pid in focusable():
|
||||
panel = locate(window, pid)
|
||||
if panel and panel["width"] > 1 and panel["height"] > 1 and \
|
||||
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
|
||||
panel.pop("root", None)
|
||||
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
|
||||
(now.get("window"), now.get("display"))})
|
||||
return {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
|
||||
def panels(limit=None):
|
||||
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus.
|
||||
|
||||
One deadline (PANELS_DEADLINE) covers the whole run, inside the server's timeout: past
|
||||
it, the panels found so far come back with a "why" saying the headset was too slow.
|
||||
"""
|
||||
DEADLINE[0] = time.monotonic() + (PANELS_DEADLINE if limit is None else limit)
|
||||
now, found, unfound, late = {}, [], 0, False
|
||||
try:
|
||||
props = gamescope()[1]
|
||||
now = focus(props)
|
||||
for window, app, pid in focusable(props):
|
||||
panel = locate(window, pid)
|
||||
unfound += panel is None
|
||||
if panel and panel["width"] > 1 and panel["height"] > 1 and \
|
||||
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
|
||||
panel.pop("root", None)
|
||||
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
|
||||
(now.get("window"), now.get("display"))})
|
||||
late = time.monotonic() >= DEADLINE[0] # the last read may have been cut short
|
||||
except OutOfTime:
|
||||
late = True
|
||||
finally:
|
||||
DEADLINE[0] = None
|
||||
out = {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
|
||||
if late:
|
||||
out["why"] = ("The headset took too long to list its panels (its X tools didn't answer in time). "
|
||||
+ ("Some may be missing. " if found else "") + "Try again, or use the Headset view.")
|
||||
elif not found:
|
||||
out["why"] = why_none(unfound)
|
||||
return out
|
||||
|
||||
|
||||
# The headset's Linux desktop, by process name (docs/how-the-frame-works.md).
|
||||
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "steamos-nested-desktop", "startplasma-wayland")
|
||||
|
||||
|
||||
def desktop_running():
|
||||
"""Whether the headset's Linux desktop (the nested Plasma session) is running."""
|
||||
try:
|
||||
pids = [p for p in os.listdir("/proc") if p.isdigit()]
|
||||
except OSError:
|
||||
return False
|
||||
for pid in pids:
|
||||
try:
|
||||
with open(f"/proc/{pid}/comm") as f:
|
||||
if f.read().strip() in DESKTOP:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def why_none(listed):
|
||||
"""Why panels() found nothing, in words the person at the computer can act on.
|
||||
listed: how many of the windows gamescope lists aren't X11 windows on any display."""
|
||||
missing = [tool for tool in ("xprop", "xwininfo") if not shutil.which(tool)]
|
||||
if missing:
|
||||
return (f"This SteamOS build has no {' or '.join(missing)}, which Frame Control uses to find the "
|
||||
"headset's panels. Use the Headset view instead, and please report your SteamOS version.")
|
||||
if listed:
|
||||
# gamescope lists windows, but none is an X11 window on any display: a Wayland
|
||||
# window (a desktop drawn straight to gamescope, say) can't be found or captured this way.
|
||||
return (f"The headset has {listed} panel{'s' if listed > 1 else ''} open, but Frame Control can't "
|
||||
f"find {'its' if listed == 1 else 'their'} window. This SteamOS build may draw "
|
||||
f"{'it' if listed == 1 else 'them'} in a way the Desktop view can't capture yet. "
|
||||
"Use the Headset view instead, and please report your SteamOS version.")
|
||||
if desktop_running():
|
||||
return ("The headset's desktop is running, but SteamOS isn't listing it as a panel. Look at it in "
|
||||
"the headset (or close and reopen it), then try again, or use the Headset view.")
|
||||
return "No app panel is open in the headset. Open the desktop or an app in the headset, then try again."
|
||||
|
||||
|
||||
def to_root(panel, fx, fy):
|
||||
|
||||
@@ -456,7 +456,9 @@ def dispatch(path, name=None, exe=None, progress=None, source=None):
|
||||
m = frame_android.install(path, source=source or os.path.basename(path))
|
||||
except frame_android.FrameError as e:
|
||||
raise WebInstallError(str(e))
|
||||
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
|
||||
message = f"Installed {m['label']} as its own app in the Steam library"
|
||||
note = frame_android.layer_note(m)
|
||||
return {"message": f"{message}. {note}" if note else message, "kind": kind, "result": m}
|
||||
try:
|
||||
import frame_titles
|
||||
except ImportError as e:
|
||||
|
||||
+564
-405
File diff suppressed because it is too large.
Load diff
+151
-31
@@ -45,11 +45,13 @@ import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
from apk_sources import fdroid as apk_fdroid # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
import frame_contact # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
@@ -133,6 +135,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -208,9 +211,14 @@ UNREACHABLE = [
|
||||
(re.compile(r"port \d+: Connection refused"),
|
||||
"The Frame refused the connection. Check Developer Mode is still on."),
|
||||
(re.compile(r"Permission denied \(publickey"),
|
||||
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
|
||||
(re.compile(r"Host key verification failed"),
|
||||
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
|
||||
"The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it "
|
||||
"again (Developer Mode must be on)."),
|
||||
# Before "Host key verification failed", which ssh prints after each of these.
|
||||
(re.compile(r"No \S+ host key is known for "),
|
||||
"The Frame's SSH identity isn't saved yet. Reconnecting saves it; or check and trust it on the Devices tab."),
|
||||
(re.compile(r"REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed"),
|
||||
"The Frame's SSH identity has changed: after SteamOS was reinstalled or reset, or a different device "
|
||||
"answering at its address. Check the new key on the Devices tab before trusting it."),
|
||||
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
|
||||
"The connection to the Frame dropped. Try again."),
|
||||
]
|
||||
@@ -332,20 +340,51 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
if r.returncode == 255 and repair_ssh_config(err):
|
||||
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
|
||||
if r.returncode == 255 and LINK and unreachable(err):
|
||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||
# ssh never reached the Frame (see frame_telemetry.diagnostic): judged on ssh's stderr only,
|
||||
# never on the command's output.
|
||||
stderr = (r.stderr if text else (r.stderr or b"").decode(errors="replace")) or ""
|
||||
if frame_host.ssh_link_failed(r.returncode, strip_ansi(stderr)):
|
||||
frame_host.link_failure(failure)
|
||||
raise failure
|
||||
return r.stdout
|
||||
|
||||
|
||||
_config_repaired = False
|
||||
|
||||
|
||||
def repair_ssh_config(err):
|
||||
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
|
||||
once per run. -> True if it did, so the command is worth retrying."""
|
||||
global _config_repaired
|
||||
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
|
||||
return False
|
||||
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
|
||||
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
|
||||
config = frame_devices.ssh_config()
|
||||
if not named.lower().endswith("/" + config.name.lower()):
|
||||
return False # a key or another file: not ours to rewrite
|
||||
_config_repaired = True
|
||||
try:
|
||||
if frame_devices.repair_permissions(config):
|
||||
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
@@ -503,8 +542,8 @@ def save_shots(body):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -663,7 +702,7 @@ PASTE = r"""set -u
|
||||
text=$(cat; printf x); text=${text%x}
|
||||
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "plasmashell is not running: open the desktop in the headset first." >&2
|
||||
echo "The headset's desktop isn't open (plasmashell is not running). Open the desktop in the headset, then try again." >&2
|
||||
exit 2
|
||||
fi
|
||||
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
|
||||
@@ -675,6 +714,7 @@ else
|
||||
exit 2
|
||||
fi
|
||||
"""
|
||||
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE says when the headset's desktop isn't open
|
||||
# base64 keeps the script intact through every local shell's quoting rules.
|
||||
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
|
||||
|
||||
@@ -691,7 +731,13 @@ def clipboard(body):
|
||||
text = body.get("text")
|
||||
if not isinstance(text, str) or not text:
|
||||
raise Failure("nothing to send", 400)
|
||||
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||
try:
|
||||
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
|
||||
except Failure as e:
|
||||
if DESKTOP_CLOSED in str(e):
|
||||
# The headset's state, for the person to change, not a fault: a 409 isn't reported as one.
|
||||
raise Failure(str(e), 409) from None
|
||||
raise
|
||||
|
||||
|
||||
# ---- keyboard and pointer (KDE Connect on the Frame, see frame_input_agent.py) ----
|
||||
@@ -1192,6 +1238,14 @@ def open_thing(body):
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "shot":
|
||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
if not saved.exists():
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.Unreachable as e:
|
||||
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
@@ -1219,8 +1273,8 @@ def android(body):
|
||||
|
||||
def work():
|
||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
|
||||
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
@@ -1251,7 +1305,7 @@ def android(body):
|
||||
" and shared it" if frame_telemetry.enabled("compat") else " on this computer")
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
@@ -1268,8 +1322,12 @@ def apk_installed(info, meta, error, seconds):
|
||||
in_catalog = bool(pkg) and pkg in by_pkg
|
||||
# Package names only for catalogue apps, which are public; a private APK's name stays here.
|
||||
# No version: a local rebuild can share a catalogue app's package name but carry anything in its version.
|
||||
# frame_android.install re-raises anything that isn't a FrameError, and whoever
|
||||
# catches it (a job, a request) reports it with its traceback: once is enough.
|
||||
frame_telemetry.install_finished("apk", error is None, seconds, error, catalog=in_catalog,
|
||||
package=pkg if in_catalog else None)
|
||||
diagnose=error is None or isinstance(error, frame_android.FrameError),
|
||||
package=pkg if in_catalog else None,
|
||||
xr_layer_missing=True if (info or {}).get("xr_layer_missing") else None)
|
||||
if error is not None and pkg and frame_telemetry.categorize(error)[0] in APK_FAULTS:
|
||||
frame_catalog.add_report(pkg, info.get("version"), result="install_failed", notes=str(error)[:300],
|
||||
via="install", label=info.get("label"))
|
||||
@@ -1388,7 +1446,7 @@ def titles(body):
|
||||
try:
|
||||
m = getattr(frame_titles, action)(gid)
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
|
||||
|
||||
|
||||
@@ -1808,8 +1866,12 @@ def _webinstall_run(plan, job):
|
||||
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
|
||||
job["phase"] = "error"
|
||||
# An APK that failed to install was counted by apk_installed.
|
||||
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
|
||||
apk_install = stage == "install" and plan.get("kind") == "apk"
|
||||
if not isinstance(e, frame_webinstall.Cancelled) and not apk_install:
|
||||
frame_telemetry.install_finished("web", False, error=e, stage=stage, kind_detail=plan.get("kind"))
|
||||
elif apk_install and not isinstance(e, known):
|
||||
# Not a FrameError, so apk_installed left its diagnostic to whoever caught it: here.
|
||||
frame_telemetry.diagnostic("web install", e)
|
||||
finally:
|
||||
with _web_lock:
|
||||
job.pop("_conn", None)
|
||||
@@ -1886,23 +1948,36 @@ def _pid_alive(pid):
|
||||
return True
|
||||
|
||||
|
||||
def sweep_tmp():
|
||||
"""Delete download and title staging folders left by a server killed mid-install.
|
||||
def _tmp_places():
|
||||
"""Where each kind of staging folder goes, and its name before the server's PID."""
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
|
||||
|
||||
Folders carry the server's PID, so only a dead server's are taken.
|
||||
|
||||
def sweep_tmp(own=False):
|
||||
"""Delete staging folders (downloads, unzipped titles, patched APKs, staged files,
|
||||
app-index downloads) left by a server that stopped mid-way.
|
||||
|
||||
Folders carry the server's PID, so only a dead server's are taken; own=True (on
|
||||
the way out, see main) takes this server's too.
|
||||
"""
|
||||
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
|
||||
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
|
||||
_sweep_one(prefix, d)
|
||||
for folder, prefix in _tmp_places():
|
||||
try:
|
||||
found = list(folder.glob(f"{prefix}*"))
|
||||
except OSError:
|
||||
continue
|
||||
for d in found:
|
||||
_sweep_one(prefix, d, own)
|
||||
|
||||
|
||||
def _sweep_one(prefix, d):
|
||||
def _sweep_one(prefix, d, own=False):
|
||||
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
|
||||
if not m:
|
||||
return
|
||||
pid = int(m[1])
|
||||
try:
|
||||
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
|
||||
if (own if pid == os.getpid() else not _pid_alive(pid)) and d.is_dir():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -2151,6 +2226,7 @@ POST = {
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
||||
"/api/devices": lambda body: devices_post(body)}
|
||||
|
||||
@@ -2246,7 +2322,7 @@ def push_file(path, dest="Downloads/"):
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -2254,6 +2330,11 @@ def push_file(path, dest="Downloads/"):
|
||||
return f"Sent {name} to ~/{dest}"
|
||||
|
||||
|
||||
class ClientGone(Exception):
|
||||
"""The page went away (a reload, the app quitting) before its reply was written:
|
||||
nobody to answer, and nothing went wrong here."""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
@@ -2286,8 +2367,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# Nobody may frame the UI (clickjacking).
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
try:
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
|
||||
raise ClientGone() from e
|
||||
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
@@ -2330,6 +2414,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
from apk_sources import _images
|
||||
try:
|
||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||
except ClientGone:
|
||||
raise
|
||||
except Exception:
|
||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||
elif path == "/api/sources/details":
|
||||
@@ -2380,6 +2466,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
self.send_json(frame_telemetry.state())
|
||||
elif path == "/api/contact":
|
||||
self.send_json(frame_contact.state())
|
||||
elif path == "/api/computer/state":
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
@@ -2405,6 +2493,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
headers=[("X-Capture-Source", "gamescope")])
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except ValueError as e:
|
||||
@@ -2436,9 +2526,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||
result = handler(body)
|
||||
self.send_json(result)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
@@ -2614,6 +2707,10 @@ class LoopbackServer(ThreadingHTTPServer):
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
if not isinstance(sys.exc_info()[1], ClientGone):
|
||||
super().handle_error(request, client_address)
|
||||
|
||||
|
||||
_ONE_SERVER = None
|
||||
|
||||
@@ -2644,6 +2741,7 @@ def main():
|
||||
sweep_tmp()
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
frame_contact.start()
|
||||
global LINK, _ONE_SERVER
|
||||
if not LOCAL:
|
||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||
@@ -2651,17 +2749,23 @@ def main():
|
||||
LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None,
|
||||
mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable)
|
||||
LINK.work_lock, LINK.work = _work_lock, lambda: _work[0]
|
||||
frame_report.link = LINK # its connection summary goes in every report's diagnostics
|
||||
frame_report.start_ssh_check() # ...with which ssh this is, found once in the background
|
||||
LINK.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
def watch_stdin():
|
||||
sys.stdin.buffer.read()
|
||||
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
|
||||
# and if a signal stops the server first, Python aborts (SIGABRT) at exit
|
||||
# when it can't take that lock back from this thread.
|
||||
while os.read(0, 4096):
|
||||
pass
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -2679,6 +2783,22 @@ def main():
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
_purge_titles(now=float("inf")) # unconfirmed title uploads
|
||||
# Staging folders of work still running (a patched APK mid-copy, an index
|
||||
# download): leaving below skips the cleanup their TemporaryDirectory would
|
||||
# get at interpreter exit. sweep_tmp at the next start catches any missed.
|
||||
sweep_tmp(own=True)
|
||||
# Stopped as asked, and everything above is cleaned up. Leave now, without
|
||||
# Python's interpreter teardown: daemon threads are still running (app index
|
||||
# downloads, the stdin watcher, telemetry, the headset link, request handlers)
|
||||
# and none can be stopped promptly. Tearing the interpreter down under them
|
||||
# occasionally crashed the process (SIGSEGV, seen on Python 3.13 on Linux):
|
||||
# OpenSSL's exit cleanup freed state those threads were using. That teardown
|
||||
# also runs atexit handlers and weakref finalizers; nothing here registers
|
||||
# atexit work, the only finalizers are TemporaryDirectory cleanups (swept
|
||||
# above), and the OS frees the one-server lock with the process.
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user