Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 4a489e4606 tests: skip the release draft step test on Windows
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:46:55 +11:00
saphidandClaude Opus 5.5 0304575e5b release.yml: create the draft through the REST API so its id comes straight back
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.

tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:28:07 +11:00
saphidandClaude Opus 5.5 81bf646b0e Release 0.4.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:20:54 +11:00
Alex Southwell 4f99cd75d0 Merge pull request #80 from saphid/fix/server-sigterm-segfault
Server: no more occasional segfault on SIGTERM (exit without interpreter teardown)
2026-10-08 20:17:41 +11:00
Alex Southwell 3e814cfa1a Merge pull request #79 from saphid/feat/report-connection-diagnostics
Reports: always include a scrubbed connection summary; log connector failures
2026-10-08 20:09:21 +11:00
saphid f221a5f588 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 20:00:12 +11:00
saphidandClaude Opus 5.5 b1f33c804c Connection log: whole known names before ssh's operands; spare only real tokens
Round-4 review (pr79-review-r4), opt-in log only:
- The headset's own names are replaced whole (longest first, any case) before
  ssh's hostname/host/to operands, so "talking to Jane Doe's work headset"
  can't be cut to "<host> Doe's work headset".
- Only the scrubbers' own tokens (<host>, <user>, <ip>, ...) are exempt from
  name replacement, so a display name like "<Jane Doe>" goes too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:00:10 +11:00
Alex Southwell 22df550ff2 Merge pull request #78 from saphid/fix/telemetry-connection-noise
Telemetry: stop flooding error tracking with "Frame unreachable"
2026-10-08 19:56:50 +11:00
saphidandClaude Opus 5.5 7871aa1ca0 Connection log: redact ssh's host operands first, and the headset's alias and name
Round-3 review (pr79-review-r3), opt-in log only:
- Each attempt's redaction set now includes the headset's ssh alias and
  display name (any case), and "has no addresses" no longer names it.
- ssh's hostname/host/to operands are redacted before the known names, known
  names never replace inside an existing <placeholder>, and names that are
  ssh's own words (host, hostname, to, port) aren't treated as names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:49:57 +11:00
saphidandClaude Opus 5.5 0a9bb2e0c3 Telemetry: keep the link-failure mark through re-raised errors
Review round 4 of #78: /api/android and /api/titles re-raise frame_android's
FrameError as Failure(str(e)), which dropped frame_link_failed, so their
connection failures were still reported after the status poll's first one.
Both now raise ... from e, and diagnostic() follows __cause__
(frame_telemetry.link_failed). server.ssh() judges a link failure on ssh's
stderr only, never the command's stdout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:46:54 +11:00
saphidandClaude Opus 5.5 9e70cdb48a Telemetry: decide "couldn't reach the Frame" where ssh runs, not from text
Review round 3 of #78: matching ssh's words in the message could still be
spoofed by a download's file name ("Connection closed by frame port 22.zip"
in a checksum failure lost its $exception after a poll timeout). Now
server.ssh and frame_android.ssh mark the exception (frame_host.link_failure)
when ssh itself failed to reach the headset: a time out, or exit 255 with a
line ssh starts (frame_host.ssh_link_failed). diagnostic() holds back only
marked errors in frame_unreachable / frame_not_set_up, once per session;
everything else keeps the 10-minute window. Web-link download and checksum
failures are classified download_failed before any other category.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:34:25 +11:00
saphid 9da1dea974 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 19:32:34 +11:00
saphidandClaude Opus 5.5 241e35d3b6 Reports: no custom alias, banner-only ssh version, whole user@host fields
Round-2 review (pr79-review-r2):
- The summary names the alias only as default ("frame") or custom, and the
  ~/.ssh/config line says "for the active alias" without naming it.
- ssh -V is parsed only as a banner at the start (OpenSSH_N.N[pN], optional
  LibreSSL/OpenSSL N.N.N) and rebuilt from fixed names and numbers; anything
  else is "unknown".
- scrub: ssh's whole user@host field (spaces, DOMAIN\ prefix, full domain
  names, "user@host's password:") goes before the email rule; a home folder's
  whole name runs to the next separator (apostrophes too), prefixes matched
  whatever their case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:32:34 +11:00
saphidandClaude Opus 5.5 70309d3cec Reports: connection summary in fixed words only; scrub log lines at capture
Independent review (pr79-review-r1) found leaks in the first version: a
failure's raw text was hidden with the current headset's hosts (so switching
headsets let the old one's name through), unknown hosts and other cases
survived, Windows user names with spaces partly survived, the PATH scan ran on
every preview, and the log throttle only caught consecutive repeats.

- The always-on summary has no free text: the current error and last failure
  are a stage plus the telemetry error category (decided when the failure
  happens) and how long ago. ssh -V is reduced to its version words.
- The opt-in server log line is scrubbed when written, with that attempt's
  hosts (case-insensitive), any name ssh gives after hostname/host/to, and
  then the shared scrubber.
- frame_telemetry.scrub: a Windows home folder's whole name (spaces too) and
  the whole user part of ssh's user@host (spaces, DOMAIN\user) become <user>.
- The ssh discovery runs once in the background from server start; a report
  waits at most 0.3 s for it.
- The log throttle keeps per-failure timestamps and counts, bounded to 32.
- Regression tests for each case; privacy.md says exactly what's sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:21:14 +11:00
saphid 76878403c3 Merge origin/main into fix/telemetry-connection-noise
# Conflicts:
#	tests/test_telemetry.py
2026-10-08 19:21:09 +11:00
saphidandClaude Opus 5.5 105b92a0dd Telemetry: count only lines that start as ssh's link failures
Review round 2 of #78: FRAME_LINK_FAILED matched its words anywhere, so a
web-link checksum mismatch for a file named kex_exchange_identification.zip,
after a status-poll timeout, lost its $exception. Each pattern is now
anchored to the start of a line as ssh prints it (re.M), and our own
"Timed out talking to HOST" must be the whole line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:19:21 +11:00
Alex Southwell d356d9a9d5 Merge pull request #75 from saphid/fix/apk-install-instant-failures
APK installs: ship the OpenXR layer so the Windows installer installs it; CI checks the install; categorize failures
2026-10-08 19:17:53 +11:00
saphid 6f5aa33197 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 19:13:53 +11:00
saphidandClaude Opus 5.5 e47eefec7c Telemetry: hold back only ssh's own link failures for the session
Review of #78: the once-per-session hold applied to anything classified
frame_unreachable, so after one status-poll timeout a web-link download whose
connection reset ("download failed: ... Connection reset by peer") lost its
$exception. Now the hold needs ssh's own wording (connect to host, could not
resolve, timed out talking to, banner exchange, mux/client_loop, connection
closed/reset by HOST port N); everything else keeps the 10-minute window.
Web-link download failures are download_failed. A bare "ssh exited 255" is
no longer frame_unreachable: the command on the Frame may have exited 255.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:13:05 +11:00
saphid d92a9fc7ae Merge origin/main into fix/telemetry-connection-noise 2026-10-08 19:09:12 +11:00
saphid 875e92ea7b Merge remote-tracking branch 'origin/main' into fix/apk-install-instant-failures 2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 f9a87e76e8 Web-link APK installs report an unexpected install failure once
Review pr75-review-r2: _webinstall_run catches non-FrameError APK install
failures without a diagnostic, and apk_installed now leaves those to the
caller, so they went unreported. Report them there; test the full worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 95ccae5494 Show the missing-layer warning on every install path; report an unexpected install failure once
Review findings on PR #75 (pr75-review-r1):
- Catalogue, alternate-version and web-link installs now append the
  "installed without the OpenXR layer" warning to their completion message
  (frame_android.layer_note); dropped files already showed vr_issues.
- A non-FrameError that install() re-raises is reported by whoever catches
  it (job or request handler, with its traceback), so apk_installed sends
  install_finished without its own diagnostic for it (install_finished
  diagnose=False). Still exactly one install_finished.
- Tests run the real job path for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:58:01 +11:00
saphidandClaude Opus 5.5 9d062b7ba0 Ship the OpenXR layer gzipped so the Windows installer installs it; CI installs and checks
electron-builder's 7-Zip compresses a bare arm64 ELF with its ARM64 branch
filter (method 0A), which the NSIS installer's extractor can't decode and
silently skips. Every installed Windows copy (0.4.0, 0.4.1) lacked
libXrApiLayer_FRAME_compat.so, so VR APK installs failed instantly.

- Commit and package the library as libXrApiLayer_FRAME_compat.so.gz
  (byte-identical when decompressed; SHA-256 checked against the README);
  frame_android decompresses it, and a corrupt file counts as missing.
- build.sh writes the .gz.
- check-resources.js also runs from the command line against an installed
  copy, comparing every file with the unpacked build.
- release.yml: the Windows job installs the NSIS installer silently and runs
  that check.
- Tests: no bare ELF under prebuilt/, packaging filter ships only the .gz.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:47:00 +11:00
saphidandClaude Opus 5.5 67bb71a708 Reports: always include a scrubbed connection summary; log connector failures
Three Windows reports said "ssh frame works in the terminal, but the app can't
find the headset", and their diagnostics held only versions: activity and the
server log are opt-in, and the connector never logged its failures anyway.

- frame_report.diagnostics always adds a connection summary (under "Include
  diagnostics"): connector phase, failed stage, attempt and reason; headset
  count and active alias; the current error and last failure plus ssh's last
  line, with the headset's addresses/hosts and user@ hidden and then the usual
  scrub; each address tried as its kind only (.local, ipv4, ipv6 link-local,
  tailscale, hostname, alias, and what a name resolved to) with its probe
  state; gateway/Tailscale; the ssh the app runs by kind (never its path),
  `ssh -V`, other ssh kinds on PATH; and whether ~/.ssh/config has the managed
  block and a hand-written Host for the alias.
- frame_link prints each failed attempt (stage, message, ssh's last line,
  address kinds) to stderr, scrubbed, so it lands in server.log; a failure that
  repeats every retry is written at most every 5 minutes.
- docs/privacy.md and the report dialog say exactly what the summary contains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:39:32 +11:00
saphidandClaude Opus 5.5 0b9a165d83 APK installs: install VR apps without a missing OpenXR layer, fail packaging without it, categorize failures
- frame_android: a missing, unreadable or empty OpenXR compat layer no longer
  aborts a VR install; it installs without the layer and says so (vr_issues),
  unless the layer was asked for explicitly. Patch failures are named, and any
  exception (not only FrameError) reaches the install hooks/telemetry.
- app/build/check-resources.js (electron-builder afterPack): the build fails if
  the layer's arm64-v8a library or other required resources are missing.
- frame_telemetry: new fixed error categories layer_missing, apk_repack_failed,
  tool_missing; ok=false without an error reports "other".
- server: install_finished carries xr_layer_missing when a VR APK installed
  without the layer. docs/privacy.md lists every category and the new field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:38:15 +11:00
saphidandClaude Opus 5.5 ba9706b69e Telemetry: stop flooding error tracking with "Frame unreachable"
The status poll (POST /api/comfort status) meets an asleep or absent headset
every few seconds, and each ssh failure became a $exception: ~760 in two weeks
from 0.4.0, nearly all connection timeouts. Errors whose category only means
the Frame couldn't be reached (frame_unreachable, frame_not_set_up) are now
sent at most once per category per session; other errors keep the 10-minute
per-message window.

The classifier now also files "Host is down", Windows' "Unknown error" and
"banner exchange: Connection to UNKNOWN port -1", "ssh exited 255", and
dropped shared connections (mux_client_, client_loop) as frame_unreachable
instead of "other".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:32:03 +11:00
23 changed files with 1439 additions and 63 deletions

No files matched your search

+27 -4
View File
@@ -16,8 +16,12 @@ permissions:
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.
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
@@ -28,11 +32,16 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
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)
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
@@ -64,6 +73,20 @@ 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
+79
View File
@@ -0,0 +1,79 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.2",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.1",
"version": "0.4.2",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -29,6 +29,7 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -81,7 +82,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so"
"prebuilt/**/*.so.gz"
]
},
{
+61 -6
View File
@@ -43,13 +43,20 @@ computer, exactly as they were sent.
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
`install_finished` never includes a file name, path or error message. An
error becomes one category from a fixed list (for example `apk_wrong_abi` or
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
is one. It names what was installed only when that's already public:
error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
Control's OpenXR compatibility layer, because this copy of the app is missing
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
names what was installed only when that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
@@ -93,7 +100,9 @@ scrubbed:
names, passwords, ports, paths and queries are dropped
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes.
The same error is sent at most once every 10 minutes. When ssh reports that
it couldn't reach the Frame (asleep, away, or not set up yet), that is sent
at most once per kind each time Frame Control runs.
## Report a problem
@@ -122,10 +131,56 @@ With **Include diagnostics** ticked (the default), the report adds:
- the OS, its release and CPU, and the Python version
- the Frame's SteamOS build, if it has connected since the app started
- which analytics levels are on
- a short connection summary, so "the app can't find the headset" can be
diagnosed. It is made of fixed words and counts only; no text from an
error message or from ssh, and no name you chose, goes in it:
- the connector's state (idle, connecting, connected or failed), the stage
it failed at (network, find, ssh, identity or login), the attempt number
and why it started (such as "Starting up" or "Trying again")
- how many headsets are saved; whether the active one's ssh alias is the
default `frame` or a custom one (a custom alias itself is never named);
whether it's saved or a bare ssh alias; and its number of addresses
- for the current error and the last failure: the stage and an error
category (the same fixed names as error details, such as
`frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
how long ago the last failure was. The category is decided when the
failure happens
- for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
`ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
`~/.ssh/config`, and what a name resolved to, such as
`.local->ipv6 link-local`) and how the try went (answered, unresolved,
timeout, refused, unreachable, sshfailed). Never the address itself
- when connected, the kind of address used and its round trip in ms
- whether this computer has a network gateway, and whether Tailscale is on,
off or not installed
- which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
Nix, the system one, or "other"), never its path; its version, rebuilt
from the numbers in the banner `ssh -V` prints (such as
`OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
plain OpenSSH banner is reported as `unknown`); and the kinds of any
other `ssh` on the PATH.
This is looked up once in the background after the app starts, so a
report sent straight away may say "still being checked"
- whether `~/.ssh/config` exists, whether it has Set Up Connection's
managed block for the active alias, how many managed blocks it has, and
whether a hand-written `Host` line also names the alias (yes or no; the
alias isn't named)
**Also include recent activity and the server log** is off by default,
because those lines can name files and apps. When ticked, it adds the newest
Activity lines and server log lines, without the request lines.
Activity lines and server log lines, without the request lines. The server
log has a line for each failed connection attempt: its stage, the message
shown on the connection pill, ssh's last line about it, and the address kinds
above. That free text is scrubbed when the line is written: the addresses, ssh alias
and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
report. Environment details are kept first, then the newest lines. **Show
+4
View File
@@ -249,6 +249,10 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
and keeps the current refresh rate when SteamVR refuses a requested one.
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
If this copy of Frame Control is missing the layer's library (an incomplete
install, or a file removed after installing), VR apps install without it and
the install message says so; OpenXR 1.0 apps still run. Release builds fail
to package without it (`app/build/check-resources.js`).
Verified on the headset (2026-09-28):
+5 -2
View File
@@ -190,8 +190,11 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
Its SHA-256 is recorded below with the APK checks.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
extractor skips. Frame Control decompresses it when injecting the layer. The
SHA-256 of the uncompressed library is recorded below with the APK checks.
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
+5 -2
View File
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
cmake --build "$here/build-android"
mkdir -p "$here/prebuilt/arm64-v8a"
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
# can't decode, so the installed app silently lacked the library.
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
+43 -11
View File
@@ -1,11 +1,17 @@
#!/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."""
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
Anything else fails, so the script under test can't fall back to `gh release ...`
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import os
import shutil
import subprocess
import sys
args = sys.argv[1:]
@@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f:
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
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
while i < len(args):
a = args[i]
if a == "-X":
@@ -23,7 +29,9 @@ while i < len(args):
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"):
elif a == "--jq":
jq = args[i + 1]; i += 2
elif a == "-H":
i += 2
elif a.startswith("-"):
i += 1
@@ -34,14 +42,34 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
def answer(response, fallback):
"""Print response through --jq when jq is here, else the fixed fallback text."""
if jq and shutil.which("jq"):
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
sys.stdout.write(out.stdout)
if out.returncode:
sys.exit("fakegh: jq failed: " + out.stderr)
else:
print(fallback)
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
for r in releases: # what --jq '.[]' prints
print(json.dumps(r))
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
elif method == "POST" and endpoint == repo + "releases":
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
"name": fields.get("name"), "draft": fields.get("draft") == "true",
"body": fields.get("body"), "assets": [],
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
@@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re
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")))
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
sys.exit("gh: Not Found (HTTP 404)")
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
"published %s at %s" % (fields.get("tag_name"), page))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
+79 -1
View File
@@ -255,6 +255,10 @@ class VRTests(unittest.TestCase):
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
# The library verified on the headset (its SHA-256 is in the layer's README).
import hashlib
digest = hashlib.sha256(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so']).hexdigest()
self.assertIn(digest, (Path(frame_android.XR_COMPAT) / 'README.md').read_text())
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
@@ -269,8 +273,82 @@ class VRTests(unittest.TestCase):
with zipfile.ZipFile(apk, 'w') as z:
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
with self.assertRaisesRegex(frame_android.LayerMissing, 'build.sh'):
frame_android.xr_compat_files(str(apk))
# Present but corrupt, or empty, counts as missing too.
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
with open(os.path.join(d, rel), 'wb') as f:
f.write(b'\x1f\x8b not really gzip')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
open(os.path.join(d, rel), 'wb').close()
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
def test_layer_ships_in_packaged_builds(self):
"""The arm64 library is in the repo, copied by electron-builder, and checked after packing."""
import fnmatch, json
root = Path(__file__).resolve().parents[1]
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertGreater((root / 'frame/openxr-compat' / rel).stat().st_size, 0, rel)
# No bare arm64 ELF ships: electron-builder's 7-Zip gives those an ARM64 filter the
# Windows installer can't extract, so the installed app silently lacked the library.
for f in (root / 'frame/openxr-compat/prebuilt').rglob('*'):
if f.is_file():
self.assertNotEqual(f.read_bytes()[:4], b'\x7fELF', f)
build = json.loads((root / 'app/package.json').read_text())['build']
self.assertEqual(build.get('afterPack'), 'build/check-resources.js')
entry = next(e for e in build['extraResources'] if e['from'] == '../frame/openxr-compat')
self.assertEqual(entry['to'], 'frame/openxr-compat')
check = (root / 'app/build/check-resources.js').read_text()
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertTrue(any(fnmatch.fnmatch(rel, f.replace('**/', '*/')) for f in entry['filter']), rel)
self.assertFalse(any(f.endswith('.so') for f in entry['filter']))
self.assertIn('frame/openxr-compat/' + rel, check)
def test_install_goes_ahead_without_a_missing_layer(self):
"""A copy of Frame Control without the layer installs VR apps anyway and says so."""
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, 'vr_issues': [],
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
missing = frame_android.LayerMissing(frame_android.LAYER_MISSING)
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', side_effect=missing), \
patch.object(frame_android, 'patch') as repair, \
patch.object(frame_android, '_install', return_value={'package': 'org.test.vr'}) as install, \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
frame_android.install('game.apk')
repair.assert_not_called() # nothing to add and nothing to repair: the APK goes as is
info = install.call_args.args[1]
self.assertTrue(info['xr_layer_missing'])
self.assertEqual(info['vr_issues'], [frame_android.LAYER_MISSING_NOTE])
self.assertIsNone(seen[-1][2]) # reported as a working install
# Asked for explicitly, a missing layer is still an error.
with self.assertRaises(frame_android.LayerMissing):
frame_android.install('game.apk', xr_compat=True)
self.assertIsInstance(seen[-1][2], frame_android.LayerMissing)
def test_patch_failures_are_named_and_every_failure_is_reported(self):
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', return_value={'x': b'1'}), \
patch.object(frame_android, 'patch', side_effect=frame_android.FrameError('ZIP64 APKs are unsupported')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaisesRegex(frame_android.FrameError, 'could not prepare the APK for the Frame: ZIP64'):
frame_android.install('game.apk')
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base, vr=False)), \
patch.object(frame_android, '_install', side_effect=FileNotFoundError(2, 'No such file or directory', 'ssh')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaises(FileNotFoundError):
frame_android.install('game.apk')
self.assertIsInstance(seen[-1][2], FileNotFoundError) # not only FrameErrors reach telemetry
def test_patch_rejects_corrupt_manifest_cleanly(self):
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
+255
View File
@@ -6,6 +6,7 @@ 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
@@ -599,6 +600,260 @@ class Connecting(unittest.TestCase):
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
# ---- what a failure leaves for a problem report ----
def stderr(self):
return quiet_log(self)
def report(self):
import frame_report as fr
done = threading.Thread(target=lambda: None)
done.start()
done.join()
with mock.patch.object(fr, "link", self.link), \
mock.patch.dict(fr._ssh, {"thread": done, "line": "SSH: system OpenSSH, OpenSSH 9.9p1, LibreSSL 3.3.6"}):
return fr.diagnostics()
def test_each_failure_goes_to_the_server_log_scrubbed(self):
err = self.stderr()
self.device("steamdeck-jane.invalid", "localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
line = err.getvalue()
self.assertIn("frame_link: login failed: The Frame didn't accept this computer's SSH key.", line)
self.assertIn("addresses: hostname unresolved; hostname->ipv4", line)
for leaked in ("steamdeck-jane", "127.0.0.1", "localhost"):
self.assertNotIn(leaked, line)
self.assertEqual(self.link.last_failure["stage"], "login")
def test_reports_carry_a_connection_summary_in_fixed_words(self):
self.stderr()
self.device("steamdeck-jane.invalid", "frame-t.invalid")
(self.dir / "ssh" / "config").write_text(
f"{fd.begin_mark('frame-t')}\nHost frame-t\n HostName 192.168.1.50\n User steamos\n{fd.end_mark('frame-t')}\n"
"Host frame-t\n HostName 10.0.0.7\n")
self.link.connect(["start"])
text = self.report()
self.assertIn("Connection: failed at find, attempt 1 (Starting up)", text)
self.assertIn("Headsets: 1 saved; active alias custom (saved, 2 address(es))", text)
self.assertIn("Error: find, frame_not_set_up", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, 0 min \d+ s ago")
self.assertIn("Addresses tried: hostname unresolved; hostname unresolved", text)
self.assertIn("Network: gateway yes, Tailscale not installed", text)
self.assertIn("SSH: system OpenSSH, OpenSSH 9.9p1", text)
self.assertIn("~/.ssh/config: managed block for the active alias yes (1 managed in all); "
"hand-written Host for it yes", text)
# No free text from the error or ssh at all, and not the custom alias.
for leaked in ("steamdeck-jane", "Could not resolve", "Can't find", "192.168", "10.0.0.7", "steamos",
"frame-t", str(self.dir)):
self.assertNotIn(leaked, text)
def test_a_failure_on_the_last_headset_leaves_nothing_of_it_after_switching(self):
self.stderr()
a = self.device("steamdeck-jane.invalid")
self.link.connect(["start"])
b = self.reg.add_device("frame-2", port=self.port, hosts=[])
self.reg.add_address(b["id"], "localhost", kind="lan")
self.hosts({"localhost": "ok"})
self.reg.set_active(b["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
text = self.report()
self.assertIn("Connection: connected via hostname->ipv4", text)
self.assertRegex(text, r"Last failure: find, frame_not_set_up, ")
self.assertNotIn("steamdeck-jane", text)
self.assertNotEqual(a["id"], b["id"])
def test_the_headsets_alias_and_name_stay_out_of_the_log(self):
import frame_report as fr
err = self.stderr()
d = self.reg.add_device("jane-office.example.com", name="Jane Doe's work headset", hosts=[])
self.reg.set_active(d["id"])
self.link.connect(["start"])
self.assertIn("find failed: The active headset has no addresses.", err.getvalue())
# A message that names it anyway (any case) goes too, in the log and so in a report's log.
self.link.note_failure("ssh", "JANE-OFFICE.EXAMPLE.COM: jane doe's work headset stopped answering")
log = self.dir / "server.log"
log.write_text(err.getvalue())
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
with mock.patch.object(fr, "link", self.link), mock.patch.dict(fr._ssh, {"thread": None, "line": "SSH: x"}):
text = fr.diagnostics(include_logs=True)
self.assertIn("frame_link: ssh failed", text)
for leaked in ("jane", "Jane", "JANE"):
self.assertNotIn(leaked, err.getvalue())
self.assertNotIn(leaked, text)
def quiet_log(test):
"""Catch frame_link's log lines, starting with nothing remembered."""
fl._logged.clear()
err = io.StringIO()
p = mock.patch.object(sys, "stderr", err)
p.start()
test.addCleanup(p.stop)
return err
class FailureLog(unittest.TestCase):
def test_the_same_failure_isnt_logged_every_retry(self):
err = quiet_log(self)
for _ in range(3):
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertEqual(err.getvalue().count("frame_link:"), 1)
for v in fl._logged.values():
v["at"] -= fl.LOG_REPEAT_EVERY + 1
fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
self.assertIn("(and 2 more times)", err.getvalue())
self.assertNotIn("frame.local", err.getvalue())
def test_interleaved_failures_are_throttled_too(self):
err = quiet_log(self)
for _ in range(4):
fl.log_failure("find", "The Frame isn't answering.")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.")
self.assertEqual(err.getvalue().count("frame_link:"), 2)
for i in range(fl.LOG_REMEMBER + 10): # many different failures: memory stays bounded
fl.log_failure("ssh", f"failure number {i}")
self.assertLessEqual(len(fl._logged), fl.LOG_REMEMBER)
def test_hosts_ssh_names_go_known_or_not_and_whatever_the_case(self):
err = quiet_log(self)
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname bastion-jane: Name or service not known")
fl.log_failure("ssh", "ssh stopped", "channel 0: open failed: connect to host jane-office.example.com port 22")
fl.log_failure("ssh", "ssh stopped", "kex_exchange_identification: Connection reset by steamdeck-jane",
hosts=["STEAMDECK-JANE"])
fl.log_failure("ssh", "Timed out talking to frame-jane", "")
out = err.getvalue()
self.assertIn("hostname <host>", out)
self.assertIn("connect to host <host> port 22", out)
for leaked in ("bastion-jane", "jane-office", "steamdeck-jane", "frame-jane"):
self.assertNotIn(leaked, out)
def test_headsets_named_like_sshs_words_dont_shield_the_real_host(self):
err = quiet_log(self)
for saved in ("host", "hostname", "to"):
fl.log_failure("ssh", "ssh stopped", "ssh: connect to host bastion-jane port 22: Connection refused",
hosts=[saved])
fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname jane-office.example.com: not known",
hosts=[saved])
out = err.getvalue()
self.assertIn("connect to host <host> port 22", out)
self.assertIn("hostname <host>", out)
for leaked in ("bastion-jane", "jane-office"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.hide_hosts("<host> and frame", ["host", "frame"]), "<host> and <host>")
def test_whole_names_go_before_sshs_words_and_only_real_tokens_are_spared(self):
for name, said in (("Jane Doe's work headset", "Timed out talking to Jane Doe's work headset"),
("Jane to Doe headset", "Jane to Doe headset stopped answering"),
("<Jane Doe>", "<Jane Doe> stopped answering")):
out = fl.scrub_failure(said, [name])
self.assertTrue(out.startswith("<host>") or out == "Timed out talking to <host>", out)
for leaked in ("Jane", "Doe"):
self.assertNotIn(leaked, out)
self.assertEqual(fl.scrub_failure("Timed out talking to Jane Doe's work headset", ["Jane Doe's work headset"]),
"Timed out talking to <host>")
self.assertEqual(fl.hide_hosts("<user>@<host>: <ip>", ["user", "host", "ip"]), "<user>@<host>: <ip>")
def test_windows_user_names_with_spaces_go_whole(self):
err = quiet_log(self)
with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()):
fl.log_failure("ssh", r"Bad owner or permissions on C:\Users\Jane Doe/.ssh/config", "")
fl.log_failure("login", "The Frame didn't accept this computer's SSH key.",
"Jane Doe@frame: Permission denied (publickey).")
fl.log_failure("login", "refused", r"debug | ssh said: CORP\jane@frame's password: denied")
out = err.getvalue()
self.assertIn(r"C:\Users\<user>/.ssh/config", out)
self.assertIn("<user>@<host>: Permission denied", out)
for leaked in ("Jane", "Doe", "jane", "CORP"):
self.assertNotIn(leaked, out)
def test_whole_ssh_user_at_host_fields_and_home_folders_go(self):
import frame_telemetry as tm
with mock.patch.object(tm, "_user_names", return_value=set()):
self.assertEqual(tm.scrub(r"CORP\Jane Doe@jane-office.example.com: Permission denied (publickey)."),
"<user>@<host>: Permission denied (publickey).")
self.assertEqual(tm.scrub("jane@jane-office.example.com's password: "), "<user>@<host>'s password: ")
self.assertEqual(tm.scrub(r"Bad owner on C:\Users\O'Brien/.ssh/config"), r"Bad owner on C:\Users\<user>/.ssh/config")
self.assertEqual(tm.scrub(r"c:\users\Zoë Smith.Jr\.ssh\config"), r"c:\users\<user>\.ssh\config")
self.assertEqual(tm.scrub("/users/O'Brien/x and /HOME/jane doe/y"), "/users/<user>/x and /HOME/<user>/y")
self.assertEqual(tm.scrub("write to me@example.com today"), "write to <email> today")
class ConnectionDiagnostics(unittest.TestCase):
def test_address_kinds_never_the_address(self):
self.assertEqual(fl.address_kind("frame.local", "fe80::1%eth0"), ".local->ipv6 link-local")
self.assertEqual(fl.address_kind("frame.local", "192.168.1.5"), ".local->ipv4")
self.assertEqual(fl.address_kind("frame.local"), ".local")
self.assertEqual(fl.address_kind("fe80::1%5"), "ipv6 link-local")
self.assertEqual(fl.address_kind("2001:db8::1"), "ipv6")
self.assertEqual(fl.address_kind("192.168.1.5", "192.168.1.5"), "ipv4")
self.assertEqual(fl.address_kind("100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("frame.tail1234.ts.net", "100.101.102.103"), "tailscale")
self.assertEqual(fl.address_kind("steamdeck"), "hostname")
self.assertEqual(fl.probes_summary([{"host": "frame", "label": "from ~/.ssh/config", "state": "timeout"}]),
"alias hostname timeout")
def test_hidden_hosts_leave_the_rest(self):
self.assertEqual(fl.hide_hosts("frame_link: Could not resolve hostname frame", ["frame"]),
"frame_link: Could not resolve hostname <host>")
self.assertEqual(fl.hide_hosts("ssh frame to frame.local", ["frame.local", "frame"], keep=("frame",)),
"ssh frame to <host>")
self.assertEqual(fl.hide_hosts("reset by SteamDeck", ["steamdeck"]), "reset by <host>")
self.assertEqual(fl.hide_operands("The headset took too long to answer."), "The headset took too long to answer.")
def test_ssh_version_is_rebuilt_from_its_numbers(self):
import frame_report as fr
for said, want in (("OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2\n", "OpenSSH for Windows 9.5p1, LibreSSL 3.8.2"),
("OpenSSH_9.9p1, LibreSSL 3.3.6\n", "OpenSSH 9.9p1, LibreSSL 3.3.6"),
("OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 15 Mar 2022\n", "OpenSSH 8.9p1"),
("OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n", "OpenSSH 9.6p1, OpenSSL 3.0.13"),
("OpenSSH_9.9p1-Jane-Doe-Laptop, LibreSSL 3.3.6\n", "unknown"),
("OpenSSH_9.9p1, OpenSSL jane-laptop\n", "OpenSSH 9.9p1"),
(r"C:\Users\Jane\OpenSSH-portable\ssh.exe: not found", "unknown"),
("", "unknown")):
with mock.patch.object(fr.frame_host, "run_ssh", return_value=subprocess.CompletedProcess([], 0, "", said)):
self.assertEqual(fr.ssh_version("ssh"), want, said)
def test_only_the_default_alias_is_named(self):
import frame_report as fr
self.assertEqual(fr.alias_kind("frame"), 'default ("frame")')
self.assertEqual(fr.alias_kind("jane-office.example.com"), "custom")
def test_a_slow_path_never_holds_up_a_report(self):
import frame_report as fr
release = threading.Event()
def slow():
release.wait(5)
return []
with mock.patch.dict(fr._ssh, {"thread": None, "line": None}), mock.patch.object(fr, "_ssh_on_path", slow), \
mock.patch.object(fr, "link", None), mock.patch.object(fr.shutil, "which", return_value="/usr/bin/ssh"), \
mock.patch.object(fr.frame_host, "run_ssh",
return_value=subprocess.CompletedProcess([], 0, "", "OpenSSH_9.9p1, LibreSSL 3.3.6\n")):
t0 = time.monotonic()
self.assertIn("Connection: no connector", fr.diagnostics())
self.assertIn("SSH: still being checked", fr.ssh_line())
self.assertLess(time.monotonic() - t0, 2)
thread = fr._ssh["thread"]
release.set()
thread.join(5)
self.assertTrue(fr._ssh["line"].startswith("SSH: "))
self.assertNotEqual(fr.ssh_line(), "SSH: still being checked")
def test_ssh_kinds(self):
import frame_report as fr
for path, kind in ((r"C:\WINDOWS\System32\OpenSSH\ssh.exe", "Windows OpenSSH (System32)"),
(r"C:\Program Files\OpenSSH\ssh.exe", "OpenSSH in Program Files"),
(r"C:\Program Files\Git\usr\bin\ssh.exe", "Git for Windows"),
("/usr/bin/ssh", "system OpenSSH"), ("/opt/homebrew/bin/ssh", "Homebrew or /usr/local"),
("/home/jane/bin/ssh", "other"), (None, "not found")):
self.assertEqual(fr.ssh_path_kind(path), kind)
def test_no_connector_says_so(self):
import frame_report as fr
with mock.patch.object(fr, "link", None):
self.assertIn("Connection: no connector", fr.diagnostics())
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
+106
View File
@@ -0,0 +1,106 @@
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
the workflow and run with bash (as Actions runs it) against a stand-in gh
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to
GitHub.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
def step_script():
"""The step's `run: |` block, dedented."""
lines = WORKFLOW.read_text().splitlines()
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
while lines[i].strip() != "run: |":
i += 1
indent = len(lines[i]) - len(lines[i].lstrip())
body = []
for line in lines[i + 1:]:
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
body.append(line)
return textwrap.dedent("\n".join(body)).strip() + "\n"
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
class DraftStep(unittest.TestCase):
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "step.sh").write_text(step_script())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
return p, log
def posts(self, log):
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
def patches(self, log):
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
def test_new_draft_uses_the_id_the_post_returns(self):
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
p, log = self.run_step([])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
[post] = self.posts(log)
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
self.assertIn(f, post)
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
self.assertIn("tag_name=v9.8.7", patch)
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
def test_a_rerun_reuses_the_draft_with_the_tag(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(self.posts(log), [])
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
def test_other_releases_alone_mean_a_new_draft(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
release(8, "v9.8.70", "Frame Control 9.8.70")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(len(self.posts(log)), 1)
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
def test_stops_when_the_tag_is_not_on_github(self):
p, log = self.run_step([], tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log), [])
if __name__ == "__main__":
unittest.main()
+311
View File
@@ -6,6 +6,7 @@ Run: python3 -m unittest discover -s tests
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import sys
import tempfile
import threading
@@ -20,9 +21,15 @@ sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
import frame_host # noqa: E402
import frame_telemetry as tm # noqa: E402
def link_error(message, kind=RuntimeError):
"""An error as an ssh helper raises it when ssh couldn't reach the headset."""
return frame_host.link_failure(kind(message))
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
@@ -92,6 +99,47 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
def test_connection_failures_are_sent_once_per_session(self):
# The status poll meets an asleep or absent headset every few seconds (638 timeouts from
# six people in two weeks): one event per kind per session, whatever the address or route,
# for errors marked where ssh ran as ssh failing to reach the headset.
tm.update_settings({"diagnostics": True})
with mock.patch.object(tm.time, "time", return_value=1000.0):
for ip in ("192.168.1.20", "192.168.1.21", "10.0.0.5"):
for where in ("POST /api/comfort status", "job steam"):
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Connection timed out"))
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Host is down"))
tm.diagnostic(where, link_error("Timed out talking to frame"))
tm.diagnostic("POST /api/comfort status",
link_error("ssh: Could not resolve hostname frame: No such host is known."))
with mock.patch.object(tm.time, "time", return_value=1000.0 + 10 * tm.REPEAT_WINDOW):
tm.diagnostic("POST /api/comfort status", link_error("client_loop: send disconnect: Connection reset"))
tm.diagnostic("POST /api/comfort status", link_error("ssh: Could not resolve hostname frame"))
sent = [e["properties"] for e in self.queued()]
self.assertEqual([p["error_category"] for p in sent], ["frame_unreachable", "frame_not_set_up"])
self.assertTrue(all(p["$exception_message"].startswith("ssh: ") for p in sent))
def test_only_marked_errors_are_held_for_the_session(self):
# The same words without the mark (from a download, a file name, anything not ssh) keep
# the usual 10-minute window.
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("download failed: [Errno 54] Connection reset by peer"))
tm.diagnostic("job web", RuntimeError("urlopen error [Errno 60] Operation timed out"))
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "frame_unreachable", "download_failed", "frame_unreachable"])
def test_real_errors_are_still_sent_beside_connection_failures(self):
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("POST /api/comfort status", KeyError("battery"))
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "other", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
@@ -180,6 +228,41 @@ class Scrub(unittest.TestCase):
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
def test_connection_failures_seen_from_released_versions(self):
# Wording from 0.4.0's error reports (addresses replaced), on Windows, macOS and Linux.
unreachable = [
"ssh: connect to host 192.168.1.20 port 22: Connection timed out",
"ssh: connect to host 192.168.1.20 port 22: Operation timed out",
"ssh: connect to host 192.168.1.20 port 22: No route to host",
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"ssh: connect to host 192.168.1.20 port 22: Unknown error",
"mux_client_request_session: read from master failed: Broken pipe\n"
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"client_loop: send disconnect: Connection reset",
"banner exchange: Connection to UNKNOWN port -1: Connection refused",
"Timed out talking to frame",
]
for message in unreachable:
self.assertEqual(tm.categorize(message)[0], "frame_unreachable", message)
for message in ("ssh: Could not resolve hostname frame: No such host is known.",
"ssh: Could not resolve hostname fe80::1%wireless_32773: No such host is known."):
self.assertEqual(tm.categorize(message)[0], "frame_not_set_up", message)
self.assertEqual(tm.categorize("ssh exited 1")[0], "other")
self.assertEqual(tm.categorize("ssh exited 255")[0], "other") # may be the command's own exit code
self.assertEqual(tm.categorize("download failed: [Errno 54] Connection reset by peer")[0], "download_failed")
self.assertEqual(tm.categorize("frame@10.0.0.2: Permission denied (publickey).")[0], "frame_auth")
def test_install_failure_categories(self):
import frame_android
self.assertEqual(tm.categorize(frame_android.LayerMissing(frame_android.LAYER_MISSING))[0], "layer_missing")
# The message 0.4.0 sent, so old and new builds land in the same bucket.
self.assertEqual(tm.categorize("the OpenXR compatibility layer isn't built; run "
"frame/openxr-compat/build.sh")[0], "layer_missing")
self.assertEqual(tm.categorize("could not prepare the APK for the Frame: ZIP64 APKs are unsupported")[0],
"apk_repack_failed")
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -205,6 +288,228 @@ 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."""
@@ -224,6 +529,12 @@ class ApkInstallReports(unittest.TestCase):
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
self.assertIs(self.install_finished.call_args[0][1], False)
def test_install_without_layer_is_flagged(self):
self.server.apk_installed({"package": "com.private.vr", "xr_layer_missing": True}, {}, None, 4.0)
self.assertIs(self.install_finished.call_args[1]["xr_layer_missing"], True)
self.server.apk_installed({"package": "com.private.vr"}, {}, None, 4.0)
self.assertIsNone(self.install_finished.call_args[1]["xr_layer_missing"])
def test_connection_trouble_is_not_reported(self):
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
+47 -11
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, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import base64, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
@@ -34,7 +34,8 @@ SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
XR_COMPAT_FILES = {
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
# Gzipped in the repo and the app; see frame/openxr-compat/build.sh for why.
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz',
}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -44,6 +45,18 @@ 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.
@@ -51,9 +64,11 @@ def ssh(cmd, input=None, timeout=120):
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -95,12 +110,20 @@ def xr_compat_files(apk_path):
for entry, rel in XR_COMPAT_FILES.items():
try:
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
add[entry] = f.read()
except OSError:
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
data = f.read()
add[entry] = gzip.decompress(data) if rel.endswith('.gz') else data
except (OSError, EOFError, zlib.error): # gzip.BadGzipFile is an OSError
add[entry] = b''
if not add[entry]: # missing, unreadable (antivirus, permissions) or truncated
raise LayerMissing(LAYER_MISSING)
return add
def layer_note(meta):
"""The missing-layer warning for an install's completion message, or ''."""
return LAYER_MISSING_NOTE if LAYER_MISSING_NOTE in ((meta or {}).get('vr_issues') or []) else ''
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
@@ -164,18 +187,31 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
if flatscreen is None:
flatscreen = not info['vr']
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
# changes calls SteamVR would otherwise reject.
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
# changes calls SteamVR would otherwise reject. Without it OpenXR 1.0 apps
# still run, so a copy of Frame Control that lacks it installs anyway and
# says so, unless the layer was asked for explicitly.
add = {}
if info['vr'] if xr_compat is None else xr_compat:
try:
add = xr_compat_files(apk_path)
except LayerMissing:
if xr_compat:
raise
info['vr_issues'] = list(info.get('vr_issues') or []) + [LAYER_MISSING_NOTE]
info['xr_layer_missing'] = True
with _install_lock:
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
patched = os.path.join(tmp, 'app.apk')
info['patched'] = patch(apk_path, patched, add)['patched']
try:
info['patched'] = patch(apk_path, patched, add)['patched']
except FrameError as e:
raise FrameError(f'could not prepare the APK for the Frame: {e}') from e
info['launchable'] = True
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
else:
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
except FrameError as e:
except Exception as e: # not only FrameError: every failed install is reported
_after_install(info, None, e, start)
raise
_after_install(info, meta, None, start)
+20
View File
@@ -8,6 +8,7 @@ CLI (used by the Electron app, so terminal handling lives in one place):
import hashlib
import io
import os
import re
import shlex
import shutil
import socket
@@ -36,6 +37,25 @@ 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."""
+132 -2
View File
@@ -32,12 +32,14 @@ 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
@@ -136,6 +138,115 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
return last or {"state": "timeout", "detail": "No answer"}
def _ip_kind(text):
"""ipv4, ipv6, ipv6 link-local or tailscale for an IP address (zone allowed), else None."""
try:
ip = ipaddress.ip_address((text or "").strip("[]").split("%")[0])
except ValueError:
return None
if frame_network.is_tailscale(str(ip)):
return "tailscale"
return "ipv4" if ip.version == 4 else "ipv6 link-local" if ip.is_link_local else "ipv6"
def address_kind(host, ip=None):
"""What sort of address a probe row is, for diagnostics, never the address itself:
".local", "ipv4", "ipv6", "ipv6 link-local", "tailscale" or "hostname", plus what a
name resolved to (".local->ipv6 link-local") when the probe got that far."""
h = (host or "").lower().rstrip(".")
kind = _ip_kind(h) or (".local" if h.endswith(".local") else
"tailscale" if frame_network.is_tailscale(h) else "hostname")
got = _ip_kind(ip) if ip and not _ip_kind(h) else None
return f"{kind}->{got}" if got and got != kind else kind
def probes_summary(probes):
"""Each address tried, as its kind and how the probe went: "alias .local->ipv4 timeout"."""
out = []
for row in probes or ():
lead = "alias " if row.get("label") == "from ~/.ssh/config" else ""
out.append(f"{lead}{address_kind(row.get('host'), row.get('ip'))} {row.get('state') or '?'}")
return "; ".join(out)
# Exactly the tokens the scrubbers write (here and frame_telemetry.scrub), nothing else in <...>.
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
def hide_hosts(text, hosts, keep=()):
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
can't recognise goes too."""
keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
parts = PLACEHOLDER.split(text) # never inside a scrubber's own <token> already there
for i in range(0, len(parts), 2):
for h in names:
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
return "".join(parts)
# ssh names the host it was going to after these words ("Could not resolve hostname X",
# "connect to host X port 22", "Timed out talking to X"): whatever follows goes, known or not.
OPERAND = re.compile(r"(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'\"()|]+)")
PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect", "find", "work", "try"}
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
def hide_operands(text):
def one(m):
word = m.group(2).rstrip(".")
dots = m.group(2)[len(word):]
return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} <host>{dots}"
return OPERAND.sub(one, text)
def scrub_failure(text, hosts=()):
"""Free text about a failed attempt, for the log: the attempt's own names, whole and
longest first (case-insensitively, never ssh's own words), then anything ssh names as a
host, then the shared scrubber (addresses, paths, user names)."""
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
def failure_category(message, raw=""):
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
word, never its text."""
return frame_telemetry.categorize(f"{message or ''}\n{raw or ''}")[0]
_logged = {} # failure line -> {"at": when last written, "repeats": since then}
LOG_REPEAT_EVERY = 300 # the same failure, retried every 30 s, goes in the log at most this often
LOG_REMEMBER = 32 # different failures remembered for that
def log_failure(stage, message, raw="", probes=(), hosts=()):
"""One failed connection attempt to stderr (the app's server.log), scrubbed when written,
with the hosts of that attempt (so a later switch of headset can't let them through)."""
hosts = list(hosts) + [r.get(k) for r in probes or () for k in ("host", "ip")]
bits = [f"frame_link: {stage} failed: {scrub_failure(message, hosts)}"]
last = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()][-1:]
if last and last[0] != message:
bits.append(f"ssh said: {scrub_failure(last[0][:300], hosts)}")
tried = probes_summary(probes)
if tried:
bits.append(f"addresses: {tried}")
line = " | ".join(bits)[:900]
t = time.monotonic()
seen = _logged.get(line)
if seen and t - seen["at"] < LOG_REPEAT_EVERY:
seen["repeats"] += 1
return
more = f" (and {seen['repeats']} more times)" if seen and seen["repeats"] else ""
_logged.pop(line, None)
_logged[line] = {"at": t, "repeats": 0}
while len(_logged) > LOG_REMEMBER:
_logged.pop(next(iter(_logged))) # the least recently written
try:
print(line + more, file=sys.stderr, flush=True)
except (OSError, ValueError, AttributeError):
pass # no stderr (a closed pipe): the page still shows the failure
def ssh_target(host, ip):
"""Where ssh should go for an address whose probe answered from `ip`: that IP, so ssh
doesn't look the name up again and try an address that didn't answer."""
@@ -188,6 +299,8 @@ 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):
@@ -436,7 +549,9 @@ class Link:
if reasons:
self.connect(reasons)
except Exception as e: # keep the loop alive whatever happens; say what went wrong
self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
message = f"{type(e).__name__}: {e}"
self.note_failure("network", message, str(e))
self.publish(phase="failed", error={"stage": "network", "message": message,
"raw": str(e)}, retry_at=now() + RETRY[-1])
finally:
with self.cond:
@@ -512,6 +627,7 @@ class Link:
self.cond.notify_all()
ok = False
try:
self.attempt_device = device # its names, for scrubbing this attempt's log lines
ok = self.attempt(device)
finally:
self.finish(gen, ok, device)
@@ -540,6 +656,7 @@ class Link:
now() + RETRY[min(self.fails, len(RETRY)) - 1])
if not self.state["error"]:
self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
self.note_failure("find", "Couldn't connect", "", self.state["probes"])
self.version += 1
self.cond.notify_all()
@@ -562,13 +679,26 @@ 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", f"{device['name']} has no addresses. Add one on the Devices tab.")
self.fail("find", "The active headset has no addresses. Add one on the Devices tab.")
return False
# 1. this computer's network
self.stage("network", "active")
+178 -2
View File
@@ -13,12 +13,18 @@ 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')
@@ -28,6 +34,7 @@ LOG_LINES = 60
ACTIVITY_LINES = 25
frame = {} # the Frame's last known SteamOS build, set by server.status()
link = None # the connector (frame_link.Link), set by server.main; None on the Frame itself
def u16(s):
@@ -59,9 +66,174 @@ def _log_tail():
return list(reversed(keep[-LOG_LINES:]))
def ssh_path_kind(path):
"""What sort of ssh a path is, never the path itself (it can hold a user name)."""
if not path:
return "not found"
p = str(path).replace("\\", "/").lower()
for marks, kind in ((("/system32/openssh/", "/sysnative/openssh/"), "Windows OpenSSH (System32)"),
(("/program files/openssh",), "OpenSSH in Program Files"),
(("/git/",), "Git for Windows"), (("msys", "cygwin"), "MSYS2/Cygwin"),
(("/opt/homebrew/", "/usr/local/", "linuxbrew"), "Homebrew or /usr/local"),
(("/nix/",), "Nix")):
if any(m in p for m in marks):
return kind
if p in ("/usr/bin/ssh", "/bin/ssh"):
return "system OpenSSH"
return "other"
def _ssh_on_path():
"""Every ssh on PATH, in the order they're found; the first is the one the app runs."""
names = ("ssh.exe", "ssh") if frame_host.WINDOWS else ("ssh",)
found, seen = [], set()
for d in os.get_exec_path():
for name in names:
cand = os.path.join(d, name)
key = os.path.normcase(os.path.abspath(cand))
if key not in seen and os.path.isfile(cand):
seen.add(key)
found.append(cand)
break
return found
# Only a real banner at the very start ("OpenSSH_9.9p1, LibreSSL 3.3.6",
# "OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2"): rebuilt from fixed names and its numbers.
BANNER_RE = re.compile(r"OpenSSH_(for_Windows_)?(\d+)\.(\d+)(p\d+)?(?=[,\s]|$)")
LIBRARY_RE = re.compile(r",?\s*(LibreSSL|OpenSSL) (\d+\.\d+\.\d+[a-z]?)(?=[,\s]|$)")
def parse_ssh_version(said):
""""OpenSSH 9.9p1, LibreSSL 3.3.6"-style text from `ssh -V`'s output, or "unknown"."""
said = (said or "").lstrip()
m = BANNER_RE.match(said)
if not m:
return "unknown"
out = f"OpenSSH{' for Windows' if m.group(1) else ''} {m.group(2)}.{m.group(3)}{m.group(4) or ''}"
lib = LIBRARY_RE.match(said, m.end())
return out + (f", {lib.group(1)} {lib.group(2)}" if lib else "")
def ssh_version(path):
"""The version from `ssh -V` (it prints to stderr), nothing else it says."""
try:
r = frame_host.run_ssh([path, "-V"], capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=5)
except (OSError, subprocess.SubprocessError) as e:
return f"couldn't run it ({type(e).__name__})"
return parse_ssh_version(r.stderr or r.stdout)
def _ssh_check():
path = shutil.which("ssh")
if not path:
return "SSH: no ssh on PATH"
others = [ssh_path_kind(p) for p in _ssh_on_path()
if os.path.normcase(os.path.abspath(p)) != os.path.normcase(os.path.abspath(path))]
others = [k for i, k in enumerate(others) if k not in others[:i]]
return f"SSH: {ssh_path_kind(path)}, {ssh_version(path)}" + (f"; also on PATH: {', '.join(others)}" if others else "")
_ssh = {"thread": None, "line": None}
_ssh_lock = threading.Lock()
SSH_WAIT = 0.3 # how long a report preview waits for the ssh check (PATH can hold slow network drives)
def start_ssh_check():
"""Look for ssh once, in the background (server.main starts it), so a report never waits
on PATH folders on slow or mapped drives."""
def run():
try:
line = _ssh_check()
except Exception as e: # a report must still go out
line = f"SSH: couldn't check ({type(e).__name__})"
_ssh["line"] = line
with _ssh_lock:
if _ssh["thread"] is None:
_ssh["thread"] = threading.Thread(target=run, name="report-ssh-check", daemon=True)
_ssh["thread"].start()
return _ssh["thread"]
def ssh_line():
start_ssh_check().join(SSH_WAIT)
return _ssh["line"] or "SSH: still being checked"
def config_line(alias):
"""Whether ~/.ssh/config has the managed block for the alias, and a hand-written Host for it."""
try:
text = frame_devices.ssh_config().read_text(encoding="utf-8", errors="replace")
except FileNotFoundError:
return "~/.ssh/config: missing"
except OSError as e:
return f"~/.ssh/config: can't read it ({type(e).__name__})"
blocks = [b["alias"] for b in frame_devices.parse_blocks(text)]
outside, inside = [], None
for line in text.splitlines():
m = frame_devices.BLOCK_RE.fullmatch(line.strip())
if m:
inside = m.group(1)
elif inside and line.strip() == frame_devices.end_mark(inside):
inside = None
elif not inside:
outside.append(line)
own = any(alias in re.split(r"[\s=]+", ln.strip())[1:] for ln in outside
if re.match(r"(?i)\s*host[\s=]", ln))
return (f"~/.ssh/config: managed block for the active alias {'yes' if alias in blocks else 'no'} "
f"({len(blocks)} managed in all); hand-written Host for it {'yes' if own else 'no'}")
def alias_kind(alias):
"""`default ("frame")` or `custom`: a name someone chose can say who they are."""
return 'default ("frame")' if alias == "frame" else "custom"
def connection_lines():
"""A short summary of the connector in fixed words only: states, stages, error categories,
kinds of address, counts. No text from errors or ssh, no custom alias, never an address."""
if link is None:
return ["Connection: no connector (this server doesn't reach a headset over SSH)"]
snap = link.snapshot()
active = link.active_device()
alias = active.get("alias") or "?"
phase, err, via = snap.get("phase") or "idle", snap.get("error"), snap.get("via")
head = f"Connection: {phase}"
if phase == "connected" and via:
rtt = via.get("rtt_ms")
head += f" via {frame_link.address_kind(via.get('host'), via.get('ip'))}" + (f" ({rtt:g} ms)" if rtt is not None else "")
elif err:
head += f" at {err.get('stage')}"
head += f", attempt {snap.get('attempt') or 0}" + (f" ({snap['reason']})" if snap.get("reason") else "")
lines = [head]
kind = ("none set up" if active.get("none") else "a bare ssh alias" if active.get("transient")
else f"saved, {len(active.get('addresses') or [])} address(es)")
lines.append(f"Headsets: {len(link.reg.devices())} saved; active alias {alias_kind(alias)} ({kind})")
if err:
lines.append(f"Error: {err.get('stage')}, {frame_link.failure_category(err.get('message'), err.get('raw'))}")
last = link.last_failure
if last:
ago = max(0, int(frame_link.now() - last.get("at", 0)))
lines.append(f"Last failure: {last.get('stage')}, {last.get('category')}, {ago // 60} min {ago % 60} s ago")
lines.append(f"Addresses tried: {frame_link.probes_summary(snap.get('probes')) or 'none yet'}")
net = snap.get("network")
if net:
ts = net.get("tailscale") or {}
lines.append(f"Network: gateway {'yes' if net.get('gateway') else 'no'}, Tailscale "
f"{'on' if ts.get('up') else 'off' if ts.get('installed') else 'not installed'}")
for part in (ssh_line, lambda: config_line(alias)):
try:
lines.append(part())
except Exception as e: # a report must still go out
lines.append(f"({type(e).__name__} while checking SSH)")
return lines
def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
"""What a report includes, scrubbed and at most `limit` UTF-16 units. Always the versions
and builds; recent activity and the server log only when asked for, since they can name
and builds, and a connection summary (connection_lines: kinds of address and states, never
the addresses, so "the app can't find the headset" can be told apart); recent activity and the server log only when asked for, since they can name
files. Sections are filled in order of use, newest lines first, so trimming drops the oldest."""
t = frame_telemetry.state()
levels = ', '.join(f"{name} {'on' if on else 'off'}" for name, on in
@@ -74,7 +246,11 @@ def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
f"Analytics: {levels}",
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
]
out = frame_telemetry.scrub('\n'.join(env), limit=limit)
try:
conn = connection_lines()
except Exception as e: # never stop a report over its diagnostics
conn = [f"Connection: summary unavailable ({type(e).__name__})"]
out = frame_telemetry.scrub('\n'.join(env) + '\n\n' + '\n'.join(conn), limit=limit)
if not include_logs:
return cut(out, limit)
sections = [('Recent activity (newest first):', [str(a)[:300] for a in list(activity)[:ACTIVITY_LINES] if isinstance(a, str)]),
+56 -9
View File
@@ -53,6 +53,12 @@ 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')
@@ -229,9 +235,16 @@ def scrub(text, limit=2000):
home = str(Path.home())
if len(home) > 3:
t = t.replace(home, '~')
t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1<user>', t)
# A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1<user>', t)
# ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
# spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
# would otherwise take only the last word of the user.
t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
'<user>@<host>', t)
for pattern, repl in SCRUBS:
t = pattern.sub(repl, t)
t = re.sub(r'(?<![\w.+\\<-])[\w.+\\-]+@(?=[A-Za-z\[<])', '<user>@', t) # any other word@host
t = IPV6_RE.sub(_ipv6, t)
for name in _user_names():
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
@@ -240,16 +253,29 @@ 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|Operation timed out|asleep|kex_exchange')),
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
('flatpak', re.compile(r'(?i)flatpak|flathub')),
@@ -349,8 +375,9 @@ def frame_seen(build, version):
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
def install_finished(kind, ok, seconds=None, error=None, **props):
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names)."""
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names).
diagnose=False when the error is reported as a diagnostic elsewhere."""
p = {'kind': kind, 'ok': bool(ok), **{k: v for k, v in props.items() if v is not None}}
if seconds is not None:
p['seconds'] = round(seconds, 1)
@@ -358,21 +385,41 @@ def install_finished(kind, ok, seconds=None, error=None, **props):
p['error_category'], code = categorize(error)
if code:
p['installer_code'] = code
elif not ok:
p['error_category'] = 'other'
capture('install_finished', p)
if error is not None and not ok:
if error is not None and not ok and diagnose:
diagnostic(f'{kind} install failed', error)
def link_failed(error):
"""Whether an ssh helper marked this error (frame_host.link_failure) as ssh failing to reach the
headset: the error itself, or one it was re-raised from (`raise Failure(...) from e`)."""
for _ in range(10): # a cause chain is short; never loop on a cycle
if error is None:
return False
if getattr(error, 'frame_link_failed', False):
return True
error = getattr(error, '__cause__', None)
return False
def diagnostic(where, error, tb=None):
"""An error for the opt-in diagnostics level: scrubbed text, and a traceback if there is one."""
if not enabled('diagnostics'):
return
message = scrub(error)
fingerprint = f'{where}|{message[:120]}'
category = categorize(error)[0]
now = time.time()
with _lock:
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
return
if category in EXPECTED_CATEGORIES and link_failed(error):
fingerprint = f'expected|{category}'
if fingerprint in _seen_errors:
return
else:
fingerprint = f'{where}|{message[:120]}'
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
return
_seen_errors[fingerprint] = now
exc_type = type(error).__name__ if isinstance(error, BaseException) else 'Error'
frames = []
@@ -385,7 +432,7 @@ def diagnostic(where, error, tb=None):
'mechanism': {'handled': True, 'type': 'generic'},
'stacktrace': {'type': 'raw', 'frames': frames[-30:]}}],
'$exception_type': exc_type, '$exception_message': message,
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
'where': scrub(where, 200), 'error_category': category},
level='diagnostics')
+3 -1
View File
@@ -456,7 +456,9 @@ def dispatch(path, name=None, exe=None, progress=None, source=None):
m = frame_android.install(path, source=source or os.path.basename(path))
except frame_android.FrameError as e:
raise WebInstallError(str(e))
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
message = f"Installed {m['label']} as its own app in the Steam library"
note = frame_android.layer_note(m)
return {"message": f"{message}. {note}" if note else message, "kind": kind, "result": m}
try:
import frame_titles
except ImportError as e:
+1 -1
View File
@@ -1272,7 +1272,7 @@
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
<p class="hint" id="bugReplaces" role="status" hidden></p>
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
<span class="sub">Frame Control's version, your OS, the Frame's SteamOS build, and a connection summary in fixed words: how connecting went, the kinds of address tried, your ssh version and whether ~/.ssh/config has the headset's entry. No error text, no custom alias name, and never the addresses themselves.</span></label>
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
+22 -7
View File
@@ -338,7 +338,7 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}")
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
@@ -347,6 +347,11 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
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
@@ -1256,8 +1261,8 @@ def android(body):
def work():
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
@@ -1288,7 +1293,7 @@ def android(body):
" and shared it" if frame_telemetry.enabled("compat") else " on this computer")
return {"message": f"Saved your report for {name}{where}", "report": r}
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
raise Failure("unknown action", 400)
@@ -1305,8 +1310,12 @@ def apk_installed(info, meta, error, seconds):
in_catalog = bool(pkg) and pkg in by_pkg
# Package names only for catalogue apps, which are public; a private APK's name stays here.
# No version: a local rebuild can share a catalogue app's package name but carry anything in its version.
# frame_android.install re-raises anything that isn't a FrameError, and whoever
# catches it (a job, a request) reports it with its traceback: once is enough.
frame_telemetry.install_finished("apk", error is None, seconds, error, catalog=in_catalog,
package=pkg if in_catalog else None)
diagnose=error is None or isinstance(error, frame_android.FrameError),
package=pkg if in_catalog else None,
xr_layer_missing=True if (info or {}).get("xr_layer_missing") else None)
if error is not None and pkg and frame_telemetry.categorize(error)[0] in APK_FAULTS:
frame_catalog.add_report(pkg, info.get("version"), result="install_failed", notes=str(error)[:300],
via="install", label=info.get("label"))
@@ -1425,7 +1434,7 @@ def titles(body):
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
@@ -1845,8 +1854,12 @@ def _webinstall_run(plan, job):
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
job["phase"] = "error"
# An APK that failed to install was counted by apk_installed.
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
apk_install = stage == "install" and plan.get("kind") == "apk"
if not isinstance(e, frame_webinstall.Cancelled) and not apk_install:
frame_telemetry.install_finished("web", False, error=e, stage=stage, kind_detail=plan.get("kind"))
elif apk_install and not isinstance(e, known):
# Not a FrameError, so apk_installed left its diagnostic to whoever caught it: here.
frame_telemetry.diagnostic("web install", e)
finally:
with _web_lock:
job.pop("_conn", None)
@@ -2724,6 +2737,8 @@ 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))