Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 90034540ae Keep the toast's fade-in, and join the test's threads on failure
From the review: opening the toast popover with .show already set skipped the
fade-in; it now opens hidden and then shows. And the superseded-test test now
joins its threads in finally, so a failure can't leave them running into
cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:21:07 +10:00
saphidandClaude Opus 5.5 5b4efc87b3 Show toasts above open dialogs, and harden the superseded-test test
From the review of the Reconnect change:

- A toast raised while a dialog was open (Reconnect refused during an install,
  say) sat under the dialog's backdrop, dimmed. The toast is now a manual
  popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
  and didn't check its threads finished. It now waits on events with time
  limits, releases everything in finally, and also checks the first test
  finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
  address wins still depends on ranking and timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 b9f96866e5 Replace Use now with a plain Reconnect, and ignore superseded tests
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.

The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.

The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 0bb2f9eb03 Offer Use now from the finished test's own order
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.

The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:54:16 +10:00
saphid 22548bc42f Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 21:32:41 +10:00
saphidandClaude Opus 5.5 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +10:00
saphidandClaude Opus 5.5 22bb9c6469 Offer Use now only where a reconnect goes, and keep newer edits and headsets apart
From the second independent review of this PR:

- Use now sends a plain reconnect, which picks the first-ranked address that
  answers, but it was offered on every tested address listed above the one in
  use. The devices list now carries each address's rank on the current network
  (frame_devices.order_addresses), and only the address a reconnect would pick
  gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
  closed the second editor and lost what was typed. Each edit now has its own
  session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
  previous headset's status before it was cleared, so Add could save its IP to
  the new headset. The dialog now renders after the old status is cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:03:13 +10:00
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +10:00
saphid 0520082586 Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 36dd5a05f2 Make the offered home address the one that gets used, and keep edits safe
An interrupted review pass pointed at four problems in the pill's dialog, each
confirmed against the Frame:

- The offer to add the Frame's LAN address appended it after the Tailscale name,
  which then kept winning on that network, so nothing changed. The offer now adds
  it first in the list (address-add takes first: true); away from home the
  Tailscale name still leads. A tested address that ranks above the one in use
  gets a Use now button that reconnects through it.
- A half-typed edit was thrown away when the connection state changed after
  focus left the input, and when the server refused the save. The edit row now
  stays until it is saved or cancelled.
- Pressing Enter twice sent the update twice.
- The offer's button could act on the previously selected headset.

Keyboard focus also stays on the same button of the same address when the rows
are rebuilt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 21:28:38 +10:00
saphidandClaude Opus 5.5 36db6b1ac6 Never cut the pill's status word short
At 1260 to 1400 px wide the header was a few pixels too tight and the pill read
"Connected · Tails…"; on a phone it read "Connected · T…". The status word now
always shows whole: when the route doesn't fit beside it, the route drops out
instead of being clipped. The wordmark gives way a little earlier so the route
shows at every desktop width above 960 px, and the smallest phones lose the
logo rather than squeeze the pill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:43 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +10:00
saphid 7ea999d5b4 Merge branch 'ui-1-layout' into ui-2-connection 2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 cb0a90a9c3 Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.

The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.

On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:39:52 +10:00
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +10:00
48 changed files with 629 additions and 3748 deletions

No files matched your search

+3 -39
View File
@@ -14,31 +14,7 @@ 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 (--verify-tag, then tag_name set explicitly), so
# scripts/publish-release.sh and `gh release upload` find it by tag.
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}"
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
fi
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:
@@ -64,27 +40,15 @@ 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: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
+1 -1
View File
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
-14
View File
@@ -6,25 +6,11 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
-79
View File
@@ -1,79 +0,0 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+6 -28
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -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; serverStarted = Date.now(); return; }
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -175,27 +175,18 @@ function stopServer() {
if (server) endServer(server);
}
function errorPage(message, title = "Frame Control couldn't start") {
function errorPage(message) {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -272,20 +263,7 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.0",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.1",
"version": "0.4.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -29,7 +29,6 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -82,7 +81,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so.gz"
"prebuilt/**/*.so"
]
},
{
+2 -4
View File
@@ -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 put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It can open Set Up Connection when the headset can't be reached, and keeps the
// Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
@@ -12,9 +12,7 @@ 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) => {
-21
View File
@@ -51,27 +51,6 @@ test("update.json assets always download from this repository's release", () =>
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
+5 -18
View File
@@ -106,24 +106,12 @@ async function getJson(url, headers) {
return JSON.parse(body);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
page: m.page || RELEASES,
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
@@ -131,9 +119,8 @@ function fromManifest(m) {
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
page: r.html_url || RELEASES,
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
+16 -4
View File
@@ -3,8 +3,9 @@
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
@@ -57,7 +58,7 @@ and ranks them:
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
@@ -122,7 +133,8 @@ file per headset instead, so saving or forgetting one headset's key never touche
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
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.
## One server at a time
+5 -10
View File
@@ -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, 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
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
@@ -88,13 +88,8 @@ counts them while they run.
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
+11 -131
View File
@@ -43,20 +43,13 @@ 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` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
`install_finished` never includes a file name, path or error message. An
error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `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:
error becomes one category from a fixed list (for example `apk_wrong_abi` or
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
is one. It names what was installed only when that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
@@ -100,30 +93,19 @@ scrubbed:
names, passwords, ports, paths and queries are dropped
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes. When ssh reports that
it couldn't reach the Frame (asleep, away, or not set up yet), that is sent
at most once per kind each time Frame Control runs.
The same error is sent at most once every 10 minutes.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
**Report a problem** is the speech-bubble button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
your analytics events. With that box ticked, the address also becomes your
**Contact email** below with follow-up questions ticked, so you remove it there
like any other. If it's a different address from the one saved there, it
replaces it, and update notices stop until you turn them on again (they were
agreed for the old address); the form says so before you send. The report then also
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
see below), so removing or changing the address later takes back the
follow-up permission given with the report too.
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
With **Include diagnostics** ticked (the default), the report adds:
@@ -131,56 +113,10 @@ With **Include diagnostics** ticked (the default), the report adds:
- the OS, its release and CPU, and the Python version
- the Frame's SteamOS build, if it has connected since the app started
- which analytics levels are on
- a short connection summary, so "the app can't find the headset" can be
diagnosed. It is made of fixed words and counts only; no text from an
error message or from ssh, and no name you chose, goes in it:
- the connector's state (idle, connecting, connected or failed), the stage
it failed at (network, find, ssh, identity or login), the attempt number
and why it started (such as "Starting up" or "Trying again")
- how many headsets are saved; whether the active one's ssh alias is the
default `frame` or a custom one (a custom alias itself is never named);
whether it's saved or a bare ssh alias; and its number of addresses
- for the current error and the last failure: the stage and an error
category (the same fixed names as error details, such as
`frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
how long ago the last failure was. The category is decided when the
failure happens
- for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
`ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
`~/.ssh/config`, and what a name resolved to, such as
`.local->ipv6 link-local`) and how the try went (answered, unresolved,
timeout, refused, unreachable, sshfailed). Never the address itself
- when connected, the kind of address used and its round trip in ms
- whether this computer has a network gateway, and whether Tailscale is on,
off or not installed
- which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
Nix, the system one, or "other"), never its path; its version, rebuilt
from the numbers in the banner `ssh -V` prints (such as
`OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
plain OpenSSH banner is reported as `unknown`); and the kinds of any
other `ssh` on the PATH.
This is looked up once in the background after the app starts, so a
report sent straight away may say "still being checked"
- whether `~/.ssh/config` exists, whether it has Set Up Connection's
managed block for the active alias, how many managed blocks it has, and
whether a hand-written `Host` line also names the alias (yes or no; the
alias isn't named)
**Also include recent activity and the server log** is off by default,
because those lines can name files and apps. When ticked, it adds the newest
Activity lines and server log lines, without the request lines. The server
log has a line for each failed connection attempt: its stage, the message
shown on the connection pill, ssh's last line about it, and the address kinds
above. That free text is scrubbed when the line is written: the addresses, ssh alias
and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Activity lines and server log lines, without the request lines.
Everything is scrubbed like error details and limited to what fits in the
report. Environment details are kept first, then the newest lines. **Show
@@ -192,67 +128,11 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never in the same visit as the first-run
privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, or when you send a problem report with follow-up questions ticked,
whatever the analytics settings are, because you chose to. With a report, the
address and choices are saved before the report is sent and stay saved if it
fails; like any change, they're sent as soon as PostHog can be reached. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. This also covers problem reports you sent
from this copy with follow-up questions ticked: if your newest choice since the
report (by change number, not the clock) no longer agrees to follow-up
questions at that address, the maintainer's inbox shows the permission as
withdrawn and leaves the address out. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
## Update checks
+4 -11
View File
@@ -24,20 +24,13 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all eight installers
are attached, each with the SHA-256 digest GitHub records. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
uses only the REST API: `gh release view` can't find a draft whose
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
stops working once it's published. From then on, running copies see the update. They check about 8
The script checks that all eight installers are attached, each with the
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
notes and each installer's digest), then publishes the release and marks it
latest. From then on, running copies see the update. They check about 8
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
-14
View File
@@ -25,20 +25,6 @@ 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
-4
View File
@@ -249,10 +249,6 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
and keeps the current refresh rate when SteamVR refuses a requested one.
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
If this copy of Frame Control is missing the layer's library (an incomplete
install, or a file removed after installing), VR apps install without it and
the install message says so; OpenXR 1.0 apps still run. Release builds fail
to package without it (`app/build/check-resources.js`).
Verified on the headset (2026-09-28):
+1 -1
View File
@@ -26,7 +26,7 @@ it twice reuses the existing process.
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
OpenVR uses background application mode, which does not start SteamVR or keep
it running. This mode also returned live timing in a read-only device probe.
+2 -5
View File
@@ -190,11 +190,8 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
extractor skips. Frame Control decompresses it when injecting the layer. The
SHA-256 of the uncompressed library is recorded below with the APK checks.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
Its SHA-256 is recorded below with the APK checks.
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
+2 -5
View File
@@ -8,8 +8,5 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
cmake --build "$here/build-android"
mkdir -p "$here/prebuilt/arm64-v8a"
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
# can't decode, so the installed app silently lacked the library.
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
Binary file not shown.
+16 -74
View File
@@ -3,65 +3,23 @@
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
#
# Everything goes through the REST API (gh api), not `gh release view/edit`:
# those look a draft up by its tag, and a draft can show tag_name
# "untagged-..." until it's published, so they report "release not found"
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
# link that dies on publishing, so update.json's page is built from the tag.
# Usage: scripts/publish-release.sh v0.4.0
set -eu
dry=""
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
tag="${1:?usage: $0 vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
page="https://github.com/$repo/releases/tag/$tag"
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
# the default branch, which isn't what was built and tested.
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
import json, re, sys
tag, path = sys.argv[1], sys.argv[2]
rels = [json.loads(line) for line in open(path) if line.strip()]
hits = [r for r in rels if r.get("tag_name") == tag]
if not hits:
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
and title.fullmatch(r.get("name") or "")]
if len(hits) != 1:
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
sys.exit("found %s for %s; expected one draft" % (why, tag))
r = hits[0]
if not r.get("draft"):
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
missing=""
for name in $expected; do
digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); n=sys.argv[2]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
d=json.load(sys.stdin); n=sys.argv[1]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
@@ -72,32 +30,16 @@ done
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
import json, sys
d = json.load(open(sys.argv[1]))
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
d=json.load(sys.stdin)
names=set(sys.argv[1].split())
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
for a in d["assets"] if a["name"] in names]}, indent=1))
EOF
old=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1]))
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
if [ -n "$dry" ]; then
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
cat "$tmp/update.json"
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
exit 0
fi
for asset in $old; do
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
done
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
echo "running copies will offer $tag at their next check"
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
echo "published $tag; running copies will offer it at their next check"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env python3
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.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."""
import json
import os
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, i = "GET", 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 in ("-H", "--jq"):
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/"
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?"):
for r in releases: # what --jq '.[]' prints
print(json.dumps(r))
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/"):
print("published %s at https://github.com/saphid/frame-control/releases/tag/%s"
% (fields.get("tag_name"), fields.get("tag_name")))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
-412
View File
@@ -1,412 +0,0 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_only_a_real_true_counts_as_consent(self):
for wrong in ("false", "true", 1, 0, [], {}):
with self.assertRaisesRegex(ValueError, "true or false"):
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
with self.assertRaisesRegex(ValueError, "true or false"):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
self.assertEqual((fc.load()["email"], self.got), ("", []))
fc.save({"email": "me@example.com", "updates": True}) # left out is no
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def reports(self):
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = self.reports()
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
consent = [e for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
self.assertEqual(self.reports()[1]["contact_rev"], 1)
fc.save({"email": ""}) # Remove my email
last = self.events()[-1]
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
("withdraw", "", 2))
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>", "<removed>"])
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
"""Update notices were agreed for the old address, not the new one (the form says so)."""
fc.save({"email": "old@example.com", "updates": True})
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
self.assertEqual(self.reports()[0]["contact_rev"], 2)
fc.save({"email": "new@example.com", "updates": True, "followup": False})
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
"""Removed while the report's own consent is on its way: the report keeps that consent's
rev (so the removal is newer) and is logged without the address."""
post, removed = tm.post, []
def slow_post(events, **kw):
post(events, **kw)
if not removed and events[0]["event"] == "contact_consent":
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
with mock.patch.object(tm, "post", side_effect=slow_post):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
report = self.reports()[0]
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>"])
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
"0.4.0", "Windows", "", "", followup, cid, rev]
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
reports = [self.report_row(), # removed later
self.report_row(cid="other"), # another copy, still agrees
self.report_row(rev=3), # sent after the removal
self.report_row(cid="moved"), # address changed later
self.report_row(cid="news-only"), # follow-up unticked later
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
self.report_row(cid="", followup=True), # no contact id: left alone
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports],
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
def test_the_change_number_decides_not_the_clock(self):
"""The clock went back between the report and the removal: the removal still counts."""
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
fc.save({"email": ""})
report = self.reports()[0]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
reports = [self.report_row(), ["short"]]
consents = [["copy", "", False, 2]]
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
mock.patch("builtins.print") as out:
fr.main()
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("follow-up permission since withdrawn", printed)
self.assertNotIn("me@example.com", printed)
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
fr.inbox()
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
def test_saving_is_not_headset_work(self):
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
import io
import server
seen = []
for path in ("/api/contact", "/api/contact/prompt"):
h = server.Handler.__new__(server.Handler)
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
h.path, h.rfile = path, io.BytesIO(body)
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
h.local_request = lambda: True
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
h.do_POST()
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
+16 -5
View File
@@ -17,7 +17,6 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
@@ -275,8 +274,7 @@ class Pins(Base):
def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
@@ -285,8 +283,7 @@ class Pins(Base):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
@@ -299,6 +296,20 @@ class Pins(Base):
class Registry(Base):
def test_an_address_added_first_wins_on_its_own_network(self):
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
# worked here before. The LAN address has to go ahead of it to be used at home.
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
addrs = self.reg.get(d["id"])["addresses"]
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
self.assertEqual(at_home[0], "192.168.1.40")
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
def test_address_editing(self):
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
-30
View File
@@ -256,36 +256,6 @@ class Repositories(unittest.TestCase):
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
def test_per_abi_builds_offer_and_download_the_arm64_one(self):
import hashlib
def build(code, name, abis):
self.files[name] = name.encode()
return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
'nativecode': abis},
'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
'added': 0}
# One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
# plus a universal and an arm64-only build sharing a code.
builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
build(3913, 'app-x86.apk', ['x86']),
build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
raw = self.root / 'splits.json'
raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
'metadata': {'name': {'en-US': 'Grayjay'}},
'versions': {str(i): b for i, b in enumerate(builds)}}}}))
source = {'id': 'test', 'url': URL}
app = fdroid._reduce(raw, source)['com.futo.platformplayer']
self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
with patch.object(fdroid, 'details', return_value=app):
for code in (None, 3912):
fdroid.download(source, 'com.futo.platformplayer', version_code=code)
self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
with self.assertRaises(SourceError): # the x86_64 build is never offered
fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
+1 -1
View File
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
-29
View File
@@ -184,35 +184,6 @@ class VersionsTest(unittest.TestCase):
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_wrong_abi_error_says_which_file_to_get(self):
import frame_telemetry
for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried
info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis}
with self.assertRaises(frame_android.FrameError) as error:
frame_android.check_installable(info)
message = str(error.exception)
self.assertIn('no arm64-v8a build (%s)' % abis[0], message)
self.assertIn('download the APK marked arm64-v8a', message)
self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi')
frame_android.check_installable({'label': 'Universal', 'min_sdk': 28,
'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']})
def test_wrong_file_reports_do_not_rate_the_app(self):
reports = frame_catalog.reports
wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed',
'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only',
'date': '2026-10-01T10:00:00'}
self.assertIsNone(reports.verdict([wrong_file]))
app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file])
self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands
# A real installer failure, a crash or a person's rating still counts.
installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK')
self.assertEqual(reports.verdict([installer])[0], 'no')
crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02')
self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no')
rated = dict(wrong_file, rating='works', date='2026-10-03')
self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works')
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
+1 -79
View File
@@ -255,10 +255,6 @@ class VRTests(unittest.TestCase):
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
# The library verified on the headset (its SHA-256 is in the layer's README).
import hashlib
digest = hashlib.sha256(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so']).hexdigest()
self.assertIn(digest, (Path(frame_android.XR_COMPAT) / 'README.md').read_text())
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
@@ -273,82 +269,8 @@ class VRTests(unittest.TestCase):
with zipfile.ZipFile(apk, 'w') as z:
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaisesRegex(frame_android.LayerMissing, 'build.sh'):
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
frame_android.xr_compat_files(str(apk))
# Present but corrupt, or empty, counts as missing too.
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
with open(os.path.join(d, rel), 'wb') as f:
f.write(b'\x1f\x8b not really gzip')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
open(os.path.join(d, rel), 'wb').close()
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
def test_layer_ships_in_packaged_builds(self):
"""The arm64 library is in the repo, copied by electron-builder, and checked after packing."""
import fnmatch, json
root = Path(__file__).resolve().parents[1]
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertGreater((root / 'frame/openxr-compat' / rel).stat().st_size, 0, rel)
# No bare arm64 ELF ships: electron-builder's 7-Zip gives those an ARM64 filter the
# Windows installer can't extract, so the installed app silently lacked the library.
for f in (root / 'frame/openxr-compat/prebuilt').rglob('*'):
if f.is_file():
self.assertNotEqual(f.read_bytes()[:4], b'\x7fELF', f)
build = json.loads((root / 'app/package.json').read_text())['build']
self.assertEqual(build.get('afterPack'), 'build/check-resources.js')
entry = next(e for e in build['extraResources'] if e['from'] == '../frame/openxr-compat')
self.assertEqual(entry['to'], 'frame/openxr-compat')
check = (root / 'app/build/check-resources.js').read_text()
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertTrue(any(fnmatch.fnmatch(rel, f.replace('**/', '*/')) for f in entry['filter']), rel)
self.assertFalse(any(f.endswith('.so') for f in entry['filter']))
self.assertIn('frame/openxr-compat/' + rel, check)
def test_install_goes_ahead_without_a_missing_layer(self):
"""A copy of Frame Control without the layer installs VR apps anyway and says so."""
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, 'vr_issues': [],
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
missing = frame_android.LayerMissing(frame_android.LAYER_MISSING)
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', side_effect=missing), \
patch.object(frame_android, 'patch') as repair, \
patch.object(frame_android, '_install', return_value={'package': 'org.test.vr'}) as install, \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
frame_android.install('game.apk')
repair.assert_not_called() # nothing to add and nothing to repair: the APK goes as is
info = install.call_args.args[1]
self.assertTrue(info['xr_layer_missing'])
self.assertEqual(info['vr_issues'], [frame_android.LAYER_MISSING_NOTE])
self.assertIsNone(seen[-1][2]) # reported as a working install
# Asked for explicitly, a missing layer is still an error.
with self.assertRaises(frame_android.LayerMissing):
frame_android.install('game.apk', xr_compat=True)
self.assertIsInstance(seen[-1][2], frame_android.LayerMissing)
def test_patch_failures_are_named_and_every_failure_is_reported(self):
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', return_value={'x': b'1'}), \
patch.object(frame_android, 'patch', side_effect=frame_android.FrameError('ZIP64 APKs are unsupported')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaisesRegex(frame_android.FrameError, 'could not prepare the APK for the Frame: ZIP64'):
frame_android.install('game.apk')
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base, vr=False)), \
patch.object(frame_android, '_install', side_effect=FileNotFoundError(2, 'No such file or directory', 'ssh')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaises(FileNotFoundError):
frame_android.install('game.apk')
self.assertIsInstance(seen[-1][2], FileNotFoundError) # not only FrameErrors reach telemetry
def test_patch_rejects_corrupt_manifest_cleanly(self):
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
-87
View File
@@ -1,87 +0,0 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
+41 -255
View File
@@ -6,7 +6,6 @@ Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import io
import json
import os
import shutil
@@ -458,6 +457,47 @@ class Connecting(unittest.TestCase):
self.assertEqual(self.routes, [])
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
d = self.device("nothing.invalid")
entered = {1: threading.Event(), 2: threading.Event()}
release = {1: threading.Event(), 2: threading.Event()}
calls = []
def probe(host, port, update=None):
calls.append(host)
n = len(calls)
entered[n].set()
release[n].wait(10)
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
with mock.patch.object(fl, "probe", probe):
try:
tests[0].start()
self.assertTrue(entered[1].wait(5), "the first test never probed")
tests[1].start()
self.assertTrue(entered[2].wait(5), "the second test never probed")
# The first finishes while the second is still probing: it mustn't show its
# rows or mark the second done.
release[1].set()
tests[0].join(10)
self.assertFalse(tests[0].is_alive())
running = self.link.snapshot()["tests"][d["id"]]
self.assertFalse(running["done"])
self.assertEqual(running["rows"][0]["detail"], "Waiting")
release[2].set()
tests[1].join(10)
self.assertFalse(tests[1].is_alive())
finally:
for e in release.values():
e.set()
for t in tests:
if t.ident: # started
t.join(10)
result = self.link.snapshot()["tests"][d["id"]]
self.assertTrue(result["done"])
self.assertEqual(result["rows"][0]["detail"], "test 2")
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
@@ -600,260 +640,6 @@ class Connecting(unittest.TestCase):
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
# ---- what a failure leaves for a problem report ----
def stderr(self):
return quiet_log(self)
def report(self):
import frame_report as fr
done = threading.Thread(target=lambda: None)
done.start()
done.join()
with mock.patch.object(fr, "link", self.link), \
mock.patch.dict(fr._ssh, {"thread": done, "line": "SSH: system OpenSSH, OpenSSH 9.9p1, LibreSSL 3.3.6"}):
return fr.diagnostics()
def test_each_failure_goes_to_the_server_log_scrubbed(self):
err = self.stderr()
self.device("steamdeck-jane.invalid", "localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
line = err.getvalue()
self.assertIn("frame_link: login failed: The Frame didn't accept this computer's SSH key.", line)
self.assertIn("addresses: hostname unresolved; hostname->ipv4", line)
for leaked in ("steamdeck-jane", "127.0.0.1", "localhost"):
self.assertNotIn(leaked, line)
self.assertEqual(self.link.last_failure["stage"], "login")
def test_reports_carry_a_connection_summary_in_fixed_words(self):
self.stderr()
self.device("steamdeck-jane.invalid", "frame-t.invalid")
(self.dir / "ssh" / "config").write_text(
f"{fd.begin_mark('frame-t')}\nHost frame-t\n HostName 192.168.1.50\n User steamos\n{fd.end_mark('frame-t')}\n"
"Host frame-t\n HostName 10.0.0.7\n")
self.link.connect(["start"])
text = self.report()
self.assertIn("Connection: failed at find, attempt 1 (Starting up)", text)
self.assertIn("Headsets: 1 saved; active alias custom (saved, 2 address(es))", text)
self.assertIn("Error: find, frame_not_set_up", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, 0 min \d+ s ago")
self.assertIn("Addresses tried: hostname unresolved; hostname unresolved", text)
self.assertIn("Network: gateway yes, Tailscale not installed", text)
self.assertIn("SSH: system OpenSSH, OpenSSH 9.9p1", text)
self.assertIn("~/.ssh/config: managed block for the active alias yes (1 managed in all); "
"hand-written Host for it yes", text)
# No free text from the error or ssh at all, and not the custom alias.
for leaked in ("steamdeck-jane", "Could not resolve", "Can't find", "192.168", "10.0.0.7", "steamos",
"frame-t", str(self.dir)):
self.assertNotIn(leaked, text)
def test_a_failure_on_the_last_headset_leaves_nothing_of_it_after_switching(self):
self.stderr()
a = self.device("steamdeck-jane.invalid")
self.link.connect(["start"])
b = self.reg.add_device("frame-2", port=self.port, hosts=[])
self.reg.add_address(b["id"], "localhost", kind="lan")
self.hosts({"localhost": "ok"})
self.reg.set_active(b["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
text = self.report()
self.assertIn("Connection: connected via hostname->ipv4", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, ")
self.assertNotIn("steamdeck-jane", text)
self.assertNotEqual(a["id"], b["id"])
def test_the_headsets_alias_and_name_stay_out_of_the_log(self):
import frame_report as fr
err = self.stderr()
d = self.reg.add_device("jane-office.example.com", name="Jane Doe's work headset", hosts=[])
self.reg.set_active(d["id"])
self.link.connect(["start"])
self.assertIn("find failed: The active headset has no addresses.", err.getvalue())
# A message that names it anyway (any case) goes too, in the log and so in a report's log.
self.link.note_failure("ssh", "JANE-OFFICE.EXAMPLE.COM: jane doe's work headset stopped answering")
log = self.dir / "server.log"
log.write_text(err.getvalue())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
with mock.patch.object(fr, "link", self.link), mock.patch.dict(fr._ssh, {"thread": None, "line": "SSH: x"}):
text = fr.diagnostics(include_logs=True)
self.assertIn("frame_link: ssh failed", text)
for leaked in ("jane", "Jane", "JANE"):
self.assertNotIn(leaked, err.getvalue())
self.assertNotIn(leaked, text)
def quiet_log(test):
"""Catch frame_link's log lines, starting with nothing remembered."""
fl._logged.clear()
err = io.StringIO()
p = mock.patch.object(sys, "stderr", err)
p.start()
test.addCleanup(p.stop)
return err
class FailureLog(unittest.TestCase):
def test_the_same_failure_isnt_logged_every_retry(self):
err = quiet_log(self)
for _ in range(3):
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertEqual(err.getvalue().count("frame_link:"), 1)
for v in fl._logged.values():
v["at"] -= fl.LOG_REPEAT_EVERY + 1
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertIn("(and 2 more times)", err.getvalue())
self.assertNotIn("frame.local", err.getvalue())
def test_interleaved_failures_are_throttled_too(self):
err = quiet_log(self)
for _ in range(4):
fl.log_failure("find", "The Frame isn't answering.")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.")
self.assertEqual(err.getvalue().count("frame_link:"), 2)
for i in range(fl.LOG_REMEMBER + 10): # many different failures: memory stays bounded
fl.log_failure("ssh", f"failure number {i}")
self.assertLessEqual(len(fl._logged), fl.LOG_REMEMBER)
def test_hosts_ssh_names_go_known_or_not_and_whatever_the_case(self):
err = quiet_log(self)
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname bastion-jane: Name or service not known")
fl.log_failure("ssh", "ssh stopped", "channel 0: open failed: connect to host jane-office.example.com port 22")
fl.log_failure("ssh", "ssh stopped", "kex_exchange_identification: Connection reset by steamdeck-jane",
hosts=["STEAMDECK-JANE"])
fl.log_failure("ssh", "Timed out talking to frame-jane", "")
out = err.getvalue()
self.assertIn("hostname <host>", out)
self.assertIn("connect to host <host> port 22", out)
for leaked in ("bastion-jane", "jane-office", "steamdeck-jane", "frame-jane"):
self.assertNotIn(leaked, out)
def test_headsets_named_like_sshs_words_dont_shield_the_real_host(self):
err = quiet_log(self)
for saved in ("host", "hostname", "to"):
fl.log_failure("ssh", "ssh stopped", "ssh: connect to host bastion-jane port 22: Connection refused",
hosts=[saved])
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname jane-office.example.com: not known",
hosts=[saved])
out = err.getvalue()
self.assertIn("connect to host <host> port 22", out)
self.assertIn("hostname <host>", out)
for leaked in ("bastion-jane", "jane-office"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.hide_hosts("<host> and frame", ["host", "frame"]), "<host> and <host>")
def test_whole_names_go_before_sshs_words_and_only_real_tokens_are_spared(self):
for name, said in (("Jane Doe's work headset", "Timed out talking to Jane Doe's work headset"),
("Jane to Doe headset", "Jane to Doe headset stopped answering"),
("<Jane Doe>", "<Jane Doe> stopped answering")):
out = fl.scrub_failure(said, [name])
self.assertTrue(out.startswith("<host>") or out == "Timed out talking to <host>", out)
for leaked in ("Jane", "Doe"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.scrub_failure("Timed out talking to Jane Doe's work headset", ["Jane Doe's work headset"]),
"Timed out talking to <host>")
self.assertEqual(fl.hide_hosts("<user>@<host>: <ip>", ["user", "host", "ip"]), "<user>@<host>: <ip>")
def test_windows_user_names_with_spaces_go_whole(self):
err = quiet_log(self)
with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()):
fl.log_failure("ssh", r"Bad owner or permissions on C:\Users\Jane Doe/.ssh/config", "")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.",
"Jane Doe@frame: Permission denied (publickey).")
fl.log_failure("login", "refused", r"debug | ssh said: CORP\jane@frame's password: denied")
out = err.getvalue()
self.assertIn(r"C:\Users\<user>/.ssh/config", out)
self.assertIn("<user>@<host>: Permission denied", out)
for leaked in ("Jane", "Doe", "jane", "CORP"):
self.assertNotIn(leaked, out)
def test_whole_ssh_user_at_host_fields_and_home_folders_go(self):
import frame_telemetry as tm
with mock.patch.object(tm, "_user_names", return_value=set()):
self.assertEqual(tm.scrub(r"CORP\Jane Doe@jane-office.example.com: Permission denied (publickey)."),
"<user>@<host>: Permission denied (publickey).")
self.assertEqual(tm.scrub("jane@jane-office.example.com's password: "), "<user>@<host>'s password: ")
self.assertEqual(tm.scrub(r"Bad owner on C:\Users\O'Brien/.ssh/config"), r"Bad owner on C:\Users\<user>/.ssh/config")
self.assertEqual(tm.scrub(r"c:\users\Zoë Smith.Jr\.ssh\config"), r"c:\users\<user>\.ssh\config")
self.assertEqual(tm.scrub("/users/O'Brien/x and /HOME/jane doe/y"), "/users/<user>/x and /HOME/<user>/y")
self.assertEqual(tm.scrub("write to me@example.com today"), "write to <email> today")
class ConnectionDiagnostics(unittest.TestCase):
def test_address_kinds_never_the_address(self):
self.assertEqual(fl.address_kind("frame.local", "fe80::1%eth0"), ".local->ipv6 link-local")
self.assertEqual(fl.address_kind("frame.local", "192.168.1.5"), ".local->ipv4")
self.assertEqual(fl.address_kind("frame.local"), ".local")
self.assertEqual(fl.address_kind("fe80::1%5"), "ipv6 link-local")
self.assertEqual(fl.address_kind("2001:db8::1"), "ipv6")
self.assertEqual(fl.address_kind("192.168.1.5", "192.168.1.5"), "ipv4")
self.assertEqual(fl.address_kind("100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("frame.tail1234.ts.net", "100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("steamdeck"), "hostname")
self.assertEqual(fl.probes_summary([{"host": "frame", "label": "from ~/.ssh/config", "state": "timeout"}]),
"alias hostname timeout")
def test_hidden_hosts_leave_the_rest(self):
self.assertEqual(fl.hide_hosts("frame_link: Could not resolve hostname frame", ["frame"]),
"frame_link: Could not resolve hostname <host>")
self.assertEqual(fl.hide_hosts("ssh frame to frame.local", ["frame.local", "frame"], keep=("frame",)),
"ssh frame to <host>")
self.assertEqual(fl.hide_hosts("reset by SteamDeck", ["steamdeck"]), "reset by <host>")
self.assertEqual(fl.hide_operands("The headset took too long to answer."), "The headset took too long to answer.")
def test_ssh_version_is_rebuilt_from_its_numbers(self):
import frame_report as fr
for said, want in (("OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2\n", "OpenSSH for Windows 9.5p1, LibreSSL 3.8.2"),
("OpenSSH_9.9p1, LibreSSL 3.3.6\n", "OpenSSH 9.9p1, LibreSSL 3.3.6"),
("OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 15 Mar 2022\n", "OpenSSH 8.9p1"),
("OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n", "OpenSSH 9.6p1, OpenSSL 3.0.13"),
("OpenSSH_9.9p1-Jane-Doe-Laptop, LibreSSL 3.3.6\n", "unknown"),
("OpenSSH_9.9p1, OpenSSL jane-laptop\n", "OpenSSH 9.9p1"),
(r"C:\Users\Jane\OpenSSH-portable\ssh.exe: not found", "unknown"),
("", "unknown")):
with mock.patch.object(fr.frame_host, "run_ssh", return_value=subprocess.CompletedProcess([], 0, "", said)):
self.assertEqual(fr.ssh_version("ssh"), want, said)
def test_only_the_default_alias_is_named(self):
import frame_report as fr
self.assertEqual(fr.alias_kind("frame"), 'default ("frame")')
self.assertEqual(fr.alias_kind("jane-office.example.com"), "custom")
def test_a_slow_path_never_holds_up_a_report(self):
import frame_report as fr
release = threading.Event()
def slow():
release.wait(5)
return []
with mock.patch.dict(fr._ssh, {"thread": None, "line": None}), mock.patch.object(fr, "_ssh_on_path", slow), \
mock.patch.object(fr, "link", None), mock.patch.object(fr.shutil, "which", return_value="/usr/bin/ssh"), \
mock.patch.object(fr.frame_host, "run_ssh",
return_value=subprocess.CompletedProcess([], 0, "", "OpenSSH_9.9p1, LibreSSL 3.3.6\n")):
t0 = time.monotonic()
self.assertIn("Connection: no connector", fr.diagnostics())
self.assertIn("SSH: still being checked", fr.ssh_line())
self.assertLess(time.monotonic() - t0, 2)
thread = fr._ssh["thread"]
release.set()
thread.join(5)
self.assertTrue(fr._ssh["line"].startswith("SSH: "))
self.assertNotEqual(fr.ssh_line(), "SSH: still being checked")
def test_ssh_kinds(self):
import frame_report as fr
for path, kind in ((r"C:\WINDOWS\System32\OpenSSH\ssh.exe", "Windows OpenSSH (System32)"),
(r"C:\Program Files\OpenSSH\ssh.exe", "OpenSSH in Program Files"),
(r"C:\Program Files\Git\usr\bin\ssh.exe", "Git for Windows"),
("/usr/bin/ssh", "system OpenSSH"), ("/opt/homebrew/bin/ssh", "Homebrew or /usr/local"),
("/home/jane/bin/ssh", "other"), (None, "not found")):
self.assertEqual(fr.ssh_path_kind(path), kind)
def test_no_connector_says_so(self):
import frame_report as fr
with mock.patch.object(fr, "link", None):
self.assertIn("Connection: no connector", fr.diagnostics())
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
-110
View File
@@ -1,110 +0,0 @@
"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
through the REST API even when its tag_name still says untagged-..., refuses
missing installers or digests, writes update.json's page from the tag, and
publishes with one PATCH. Nothing here talks to GitHub.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "publish-release.sh"
FAKEGH = ROOT / "tests" / "fakegh"
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb"]
def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
if assets is None:
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
for i, n in enumerate(INSTALLERS)]
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
"assets": assets}
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
return p, log, upload
def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
rel = draft(assets=draft()["assets"] + [old])
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
self.assertEqual(upload["version"], "9.8.7")
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
patch = next(c for c in log if "PATCH" in c)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
self.assertIn(f, patch)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
def test_an_untagged_draft_may_carry_a_subtitle(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["version"], "9.8.7")
def test_prefers_the_release_whose_tag_name_matches(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
def test_refuses_missing_or_unhashed_installers(self):
assets = draft()["assets"][1:]
assets[0] = dict(assets[0], digest=None)
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
self.assertIsNone(upload)
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("2 releases", p.stderr)
p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("no release", p.stderr)
# A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0, rel)
self.assertIn("no release", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("PATCH" in c for c in log))
p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("push it first", p.stderr)
def test_dry_run_changes_nothing(self):
p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
if __name__ == "__main__":
unittest.main()
-161
View File
@@ -1,161 +0,0 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
-18
View File
@@ -111,8 +111,6 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
("/api/open", {"what": "shot"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
@@ -123,10 +121,6 @@ class ServerGuards(unittest.TestCase):
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -285,18 +279,6 @@ class OneServer(unittest.TestCase):
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
+4 -318
View File
@@ -6,7 +6,6 @@ Run: python3 -m unittest discover -s tests
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import sys
import tempfile
import threading
@@ -21,15 +20,9 @@ sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
import frame_host # noqa: E402
import frame_telemetry as tm # noqa: E402
def link_error(message, kind=RuntimeError):
"""An error as an ssh helper raises it when ssh couldn't reach the headset."""
return frame_host.link_failure(kind(message))
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
@@ -99,47 +92,6 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
def test_connection_failures_are_sent_once_per_session(self):
# The status poll meets an asleep or absent headset every few seconds (638 timeouts from
# six people in two weeks): one event per kind per session, whatever the address or route,
# for errors marked where ssh ran as ssh failing to reach the headset.
tm.update_settings({"diagnostics": True})
with mock.patch.object(tm.time, "time", return_value=1000.0):
for ip in ("192.168.1.20", "192.168.1.21", "10.0.0.5"):
for where in ("POST /api/comfort status", "job steam"):
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Connection timed out"))
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Host is down"))
tm.diagnostic(where, link_error("Timed out talking to frame"))
tm.diagnostic("POST /api/comfort status",
link_error("ssh: Could not resolve hostname frame: No such host is known."))
with mock.patch.object(tm.time, "time", return_value=1000.0 + 10 * tm.REPEAT_WINDOW):
tm.diagnostic("POST /api/comfort status", link_error("client_loop: send disconnect: Connection reset"))
tm.diagnostic("POST /api/comfort status", link_error("ssh: Could not resolve hostname frame"))
sent = [e["properties"] for e in self.queued()]
self.assertEqual([p["error_category"] for p in sent], ["frame_unreachable", "frame_not_set_up"])
self.assertTrue(all(p["$exception_message"].startswith("ssh: ") for p in sent))
def test_only_marked_errors_are_held_for_the_session(self):
# The same words without the mark (from a download, a file name, anything not ssh) keep
# the usual 10-minute window.
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("download failed: [Errno 54] Connection reset by peer"))
tm.diagnostic("job web", RuntimeError("urlopen error [Errno 60] Operation timed out"))
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "frame_unreachable", "download_failed", "frame_unreachable"])
def test_real_errors_are_still_sent_beside_connection_failures(self):
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("POST /api/comfort status", KeyError("battery"))
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "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)
@@ -228,41 +180,6 @@ class Scrub(unittest.TestCase):
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
def test_connection_failures_seen_from_released_versions(self):
# Wording from 0.4.0's error reports (addresses replaced), on Windows, macOS and Linux.
unreachable = [
"ssh: connect to host 192.168.1.20 port 22: Connection timed out",
"ssh: connect to host 192.168.1.20 port 22: Operation timed out",
"ssh: connect to host 192.168.1.20 port 22: No route to host",
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"ssh: connect to host 192.168.1.20 port 22: Unknown error",
"mux_client_request_session: read from master failed: Broken pipe\n"
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"client_loop: send disconnect: Connection reset",
"banner exchange: Connection to UNKNOWN port -1: Connection refused",
"Timed out talking to frame",
]
for message in unreachable:
self.assertEqual(tm.categorize(message)[0], "frame_unreachable", message)
for message in ("ssh: Could not resolve hostname frame: No such host is known.",
"ssh: Could not resolve hostname fe80::1%wireless_32773: No such host is known."):
self.assertEqual(tm.categorize(message)[0], "frame_not_set_up", message)
self.assertEqual(tm.categorize("ssh exited 1")[0], "other")
self.assertEqual(tm.categorize("ssh exited 255")[0], "other") # may be the command's own exit code
self.assertEqual(tm.categorize("download failed: [Errno 54] Connection reset by peer")[0], "download_failed")
self.assertEqual(tm.categorize("frame@10.0.0.2: Permission denied (publickey).")[0], "frame_auth")
def test_install_failure_categories(self):
import frame_android
self.assertEqual(tm.categorize(frame_android.LayerMissing(frame_android.LAYER_MISSING))[0], "layer_missing")
# The message 0.4.0 sent, so old and new builds land in the same bucket.
self.assertEqual(tm.categorize("the OpenXR compatibility layer isn't built; run "
"frame/openxr-compat/build.sh")[0], "layer_missing")
self.assertEqual(tm.categorize("could not prepare the APK for the Frame: ZIP64 APKs are unsupported")[0],
"apk_repack_failed")
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -288,228 +205,6 @@ class Compat(Base):
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
class LinkFailureProvenance(Base):
"""Only ssh, where it runs, decides that it couldn't reach the headset; the error report
then holds that back for the session. Nothing in a message can claim it."""
def setUp(self):
super().setUp()
import frame_android
import frame_webinstall
import server
self.server, self.android, self.web = server, frame_android, frame_webinstall
for target, name, kw in ((server, "ensure_master", {}), (server, "LINK", {"new": None}),
(server, "repair_ssh_config", {"return_value": False})):
p = mock.patch.object(target, name, **kw)
p.start()
self.addCleanup(p.stop)
tm.update_settings({"diagnostics": True})
def ssh_fails(self, module, returncode, stderr, stdout=""):
"""Run module.ssh with ssh exiting `returncode`; -> the exception, as the route handler gets it."""
done = subprocess.CompletedProcess(["ssh"], returncode, stdout, stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=done):
try:
module.ssh("true")
except Exception as e: # noqa: BLE001
return e
self.fail("ssh did not raise")
def poll_fails(self):
e = self.ssh_fails(self.server, 255, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
tm.diagnostic("POST /api/comfort status", e)
def test_a_status_poll_that_cannot_reach_the_frame_is_sent_once(self):
for _ in range(5):
self.poll_fails()
for stderr in ("Warning: Permanently added '10.0.0.5' (ED25519) to the list of known hosts.\r\n"
"kex_exchange_identification: read: Connection reset by peer\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"\x1b[0mssh: connect to host 10.0.0.5 port 22: Unknown error\n"):
tm.diagnostic("POST /api/comfort status", self.ssh_fails(self.server, 255, stderr))
with mock.patch.object(frame_host, "run_ssh", side_effect=subprocess.TimeoutExpired("ssh", 30)):
with self.assertRaises(self.server.Failure) as caught:
self.server.ssh("true")
tm.diagnostic("POST /api/comfort status", caught.exception)
e = self.ssh_fails(self.android, 255, "ssh: connect to host 10.0.0.5 port 22: Host is down\n")
tm.diagnostic("job steam", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_unreachable"])
def test_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."""
@@ -529,12 +224,6 @@ class ApkInstallReports(unittest.TestCase):
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
self.assertIs(self.install_finished.call_args[0][1], False)
def test_install_without_layer_is_flagged(self):
self.server.apk_installed({"package": "com.private.vr", "xr_layer_missing": True}, {}, None, 4.0)
self.assertIs(self.install_finished.call_args[1]["xr_layer_missing"], True)
self.server.apk_installed({"package": "com.private.vr"}, {}, None, 4.0)
self.assertIsNone(self.install_finished.call_args[1]["xr_layer_missing"])
def test_connection_trouble_is_not_reported(self):
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
@@ -702,16 +391,14 @@ class ReportProblem(Base):
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com", "contactFollowup": True})
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com"})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -734,9 +421,8 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", "", None, None, None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
["short"], good]
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
-162
View File
@@ -1,162 +0,0 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+15 -55
View File
@@ -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, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
@@ -33,8 +33,7 @@ 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',
# 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',
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -44,30 +43,16 @@ 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 = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0:
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
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
return p.stdout
@@ -109,29 +94,17 @@ 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:
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)
add[entry] = f.read()
except OSError:
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
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']:
# 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")
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
@@ -147,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
subprocess.run(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:
@@ -186,31 +159,18 @@ 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. 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
# changes calls SteamVR would otherwise reject.
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
with _install_lock:
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
patched = os.path.join(tmp, 'app.apk')
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['patched'] = patch(apk_path, patched, add)['patched']
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 Exception as e: # not only FrameError: every failed install is reported
except FrameError as e:
_after_install(info, None, e, start)
raise
_after_install(info, meta, None, start)
+4 -5
View File
@@ -11,17 +11,16 @@ 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 = 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)
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)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
+5 -10
View File
@@ -24,10 +24,6 @@ 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")
@@ -334,9 +330,8 @@ 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 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")
if os.name != "nt":
tmp.chmod(0o600)
# 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):
@@ -354,9 +349,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 frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
def configured_user():
-252
View File
@@ -1,252 +0,0 @@
"""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()
+10 -38
View File
@@ -241,7 +241,8 @@ def _write_config(path, text, expected):
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
if not frame_host.WINDOWS:
tmp.chmod(0o600)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
if read_config(path) != expected:
return False
@@ -270,37 +271,6 @@ 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
@@ -363,8 +333,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 = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
out = subprocess.run(["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)
@@ -376,8 +346,8 @@ def effective_port(alias, config):
def _keygen(*args):
try:
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
@@ -653,7 +623,9 @@ 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=""):
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."""
with self._changing():
d = self._find(device_id)
a = new_address(host, kind, label)
@@ -661,7 +633,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"].append(a)
d["addresses"].insert(0 if first else len(d["addresses"]), a)
self.save()
return copy.deepcopy(a)
+8 -161
View File
@@ -5,17 +5,12 @@ 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"
@@ -37,64 +32,6 @@ 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).
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)",
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)."""
@@ -216,19 +153,6 @@ 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
@@ -304,46 +228,10 @@ 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 = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
out = subprocess.run(["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():
@@ -376,65 +264,24 @@ 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 f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
return "Opened Windows App"
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", str(rdp_file(host))])
# Windows asks about the unsigned connection file first.
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
return f"Opened Remmina to {host}: {RDP_LOGIN}"
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
+18 -143
View File
@@ -32,14 +32,12 @@ import queue
import re
import socket
import subprocess
import sys
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
@@ -76,8 +74,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 = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
got = {}
@@ -110,8 +108,7 @@ 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
# 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])}"
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
except (OSError, AttributeError):
pass
left = deadline - now()
@@ -138,115 +135,6 @@ 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."""
@@ -281,6 +169,7 @@ class Link:
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,
@@ -299,8 +188,6 @@ 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
# ---- publishing ----
def publish(self, **fields):
@@ -549,9 +436,7 @@ class Link:
if reasons:
self.connect(reasons)
except Exception as e: # keep the loop alive whatever happens; say what went wrong
message = f"{type(e).__name__}: {e}"
self.note_failure("network", message, str(e))
self.publish(phase="failed", error={"stage": "network", "message": message,
self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
"raw": str(e)}, retry_at=now() + RETRY[-1])
finally:
with self.cond:
@@ -627,7 +512,6 @@ 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)
@@ -656,7 +540,6 @@ 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()
@@ -679,26 +562,13 @@ class Link:
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = {"stage": sid, "message": message, "raw": raw}
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", "The active headset has no addresses. Add one on the Devices tab.")
self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.")
return False
# 1. this computer's network
self.stage("network", "active")
@@ -896,8 +766,8 @@ class Link:
if not self.control:
return False
try:
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
return subprocess.run([*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
@@ -908,8 +778,8 @@ class Link:
pending.kill()
if self.control and self.alias:
try:
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5)
subprocess.run([*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:
@@ -1086,9 +956,13 @@ 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()
@@ -1114,8 +988,8 @@ class Link:
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
r = subprocess.run(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")
@@ -1242,7 +1116,8 @@ 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 "")
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
first=body.get("first") is True)
if is_active and link.state["phase"] == "failed":
link.kick("retry")
msg = f"Added {a['host']}"
+13 -288
View File
@@ -6,25 +6,14 @@ 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')
@@ -34,7 +23,6 @@ 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):
@@ -66,174 +54,9 @@ 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, 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
and builds; 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
@@ -246,11 +69,7 @@ def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
f"Analytics: {levels}",
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
]
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)
out = frame_telemetry.scrub('\n'.join(env), 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)]),
@@ -288,18 +107,9 @@ 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': 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,
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
'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()),
@@ -309,9 +119,7 @@ def send(body):
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
with frame_telemetry._lock: # the lock a removal holds while wiping its address
frame_contact.redact_removed(event, started)
frame_telemetry.record_sent([event])
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}).'}
@@ -323,109 +131,26 @@ 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). 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."""
frame_compat_db.sync uses)."""
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, "
"properties.contact_followup, properties.contact_id, properties.contact_rev "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
"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 "
"ORDER BY timestamp DESC LIMIT 200")
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]'
return res.get('results') or []
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)
sys.exit('usage: frame_report.py inbox [days]')
for row in inbox(*(args[:1] or [30])):
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]))
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)
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
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(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
print(' ' + text.replace('\n', '\n '))
if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
+9 -56
View File
@@ -53,12 +53,6 @@ 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. 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')
DEFAULT_HOST = 'https://us.i.posthog.com'
LEVELS = ('usage', 'compat', 'diagnostics')
@@ -235,16 +229,9 @@ def scrub(text, limit=2000):
home = str(Path.home())
if len(home) > 3:
t = t.replace(home, '~')
# 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)
t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1<user>', 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)
@@ -253,29 +240,16 @@ 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')),
# 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|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
r'Network is unreachable|Operation timed out|asleep|kex_exchange')),
('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')),
@@ -375,9 +349,8 @@ 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, 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."""
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)."""
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)
@@ -385,41 +358,21 @@ def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **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 and diagnose:
if error is not None and not ok:
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)
category = categorize(error)[0]
fingerprint = f'{where}|{message[:120]}'
now = time.time()
with _lock:
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
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 = []
@@ -432,7 +385,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': category},
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
level='diagnostics')
+1 -3
View File
@@ -456,9 +456,7 @@ 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))
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}
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
try:
import frame_titles
except ImportError as e:
+387 -451
View File
File diff suppressed because it is too large. Load diff
+18 -96
View File
@@ -50,7 +50,6 @@ 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
@@ -134,7 +133,6 @@ 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
@@ -334,51 +332,20 @@ 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 = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
raise 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)
@@ -536,8 +503,8 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
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)
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)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -1225,14 +1192,6 @@ 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)
@@ -1260,8 +1219,8 @@ def android(body):
def work():
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
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 {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
@@ -1292,7 +1251,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)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
raise Failure(str(e))
raise Failure("unknown action", 400)
@@ -1309,12 +1268,8 @@ 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,
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)
package=pkg if in_catalog 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"))
@@ -1433,7 +1388,7 @@ def titles(body):
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
raise Failure(str(e))
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
@@ -1853,12 +1808,8 @@ 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.
apk_install = stage == "install" and plan.get("kind") == "apk"
if not isinstance(e, frame_webinstall.Cancelled) and not apk_install:
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
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)
@@ -2200,7 +2151,6 @@ 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)}
@@ -2296,7 +2246,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 = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = subprocess.run(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:
@@ -2304,11 +2254,6 @@ 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
@@ -2341,11 +2286,8 @@ 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'")
try:
self.end_headers()
self.wfile.write(data)
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
raise ClientGone() from e
self.end_headers()
self.wfile.write(data)
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
@@ -2388,8 +2330,6 @@ 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":
@@ -2440,8 +2380,6 @@ 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":
@@ -2467,8 +2405,6 @@ 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:
@@ -2500,12 +2436,9 @@ 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)
# Not headset work: switching headsets mustn't wait for (or refuse) these.
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
with (contextlib.nullcontext() if path == "/api/devices" 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)
@@ -2681,10 +2614,6 @@ 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
@@ -2715,7 +2644,6 @@ 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)
@@ -2723,23 +2651,17 @@ 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():
# 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
sys.stdin.buffer.read()
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