mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-08 17:00:45 +02:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a489e4606 | ||
|
|
0304575e5b | ||
|
|
81bf646b0e | ||
|
|
4f99cd75d0 | ||
|
|
3e814cfa1a | ||
|
|
6e566db1a7 | ||
|
|
f221a5f588 | ||
|
|
b1f33c804c | ||
|
|
db9bee2903 | ||
|
|
22df550ff2 | ||
|
|
7871aa1ca0 | ||
|
|
0a9bb2e0c3 | ||
|
|
b25855d13a | ||
|
|
9e70cdb48a | ||
|
|
9da1dea974 | ||
|
|
241e35d3b6 | ||
|
|
70309d3cec | ||
|
|
76878403c3 | ||
|
|
105b92a0dd | ||
|
|
d356d9a9d5 | ||
|
|
6f5aa33197 | ||
|
|
e47eefec7c | ||
|
|
d92a9fc7ae | ||
|
|
875e92ea7b | ||
|
|
f9a87e76e8 | ||
|
|
95ccae5494 | ||
|
|
d58a926257 | ||
|
|
9292ce0a4c | ||
|
|
f5f3a1f9ca | ||
|
|
9d062b7ba0 | ||
|
|
a46c28d6c8 | ||
|
|
a41f635a7c | ||
|
|
67bb71a708 | ||
|
|
0b9a165d83 | ||
|
|
88a2fe6728 | ||
|
|
ba9706b69e |
No files matched your search
@@ -14,7 +14,40 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# One job makes the draft, before the builds: three matrix jobs each running
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. It is created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# A rerun finds the draft an earlier attempt made.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||
if [ -z "$id" ]; then
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
|
||||
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
|
||||
-F draft=true -f body="" --jq .id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
# Still runs for pull requests and manual runs, where the draft job is skipped.
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -40,15 +73,27 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
- name: Check the Windows installer installs every file
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
|
||||
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
|
||||
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
|
||||
An install_failed report saying the file had no arm64-v8a build (or needed a
|
||||
newer Android) is about that one APK file, often the wrong per-ABI download of
|
||||
an app that has an arm64 build, so it says nothing about the app and is left out.
|
||||
"""
|
||||
import re
|
||||
|
||||
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
|
||||
|
||||
|
||||
def about_app(r):
|
||||
"""False for reports about one wrong APK file rather than the app itself."""
|
||||
return not (r.get('result') == 'install_failed' and not r.get('rating')
|
||||
and FILE_FAULT.search(r.get('notes') or ''))
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
reports = [r for r in reports or () if about_app(r)]
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Checks that the app's resources hold what Frame Control can't work without.
|
||||
// The OpenXR compatibility layer is why this exists: VR APK installs need its
|
||||
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
|
||||
//
|
||||
// As electron-builder's afterPack hook it checks the unpacked app. From the
|
||||
// command line it checks an installed copy, which is what caught the Windows
|
||||
// installer silently dropping the library (see .github/workflows/release.yml):
|
||||
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
|
||||
// With a reference (the unpacked build's resources), every file in it must also
|
||||
// be in the installed copy, at the same size.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
|
||||
];
|
||||
|
||||
function resourcesDir(context) {
|
||||
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
|
||||
const app = `${context.packager.appInfo.productFilename}.app`;
|
||||
return path.join(context.appOutDir, app, "Contents", "Resources");
|
||||
}
|
||||
return path.join(context.appOutDir, "resources");
|
||||
}
|
||||
|
||||
function size(file) {
|
||||
try {
|
||||
return fs.statSync(file).size;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Required files that are missing or empty.
|
||||
function missing(dir) {
|
||||
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
|
||||
}
|
||||
|
||||
// Files under reference that aren't in dir at the same size.
|
||||
function differences(dir, reference) {
|
||||
const out = [];
|
||||
(function walk(rel) {
|
||||
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
|
||||
const r = path.join(rel, e.name);
|
||||
if (e.isDirectory()) walk(r);
|
||||
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
|
||||
}
|
||||
})("");
|
||||
return out;
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const dir = resourcesDir(context);
|
||||
const gone = missing(dir);
|
||||
if (gone.length) {
|
||||
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
|
||||
}
|
||||
};
|
||||
exports.missing = missing;
|
||||
exports.differences = differences;
|
||||
exports.REQUIRED = REQUIRED;
|
||||
|
||||
if (require.main === module) {
|
||||
const [dir, reference] = process.argv.slice(2);
|
||||
if (!dir) {
|
||||
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
|
||||
process.exit(2);
|
||||
}
|
||||
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
|
||||
if (problems.length) {
|
||||
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
|
||||
const { fromManifest, fromApi } = require("../updater");
|
||||
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
|
||||
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
|
||||
assert.strictEqual(page(tagPage), tagPage);
|
||||
// 0.4.0's real update.json: the draft's address, a 404 once published.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
|
||||
assert.strictEqual(page(undefined), tagPage);
|
||||
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
|
||||
// Paths that normalize to another repository.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
|
||||
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
|
||||
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
|
||||
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
|
||||
+18
-5
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// The release page the banner links to. update.json for 0.4.0 carried the draft's
|
||||
// address (releases/tag/untagged-...), which is dead once the release is published,
|
||||
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
|
||||
// normalizes to another repository) before shell.openExternal. So the given page is
|
||||
// ignored: the link is always this repository's tag page, built from a valid version.
|
||||
function releasePage(version) {
|
||||
const v = parseVersion(version);
|
||||
if (!v) return RELEASES;
|
||||
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
|
||||
}
|
||||
|
||||
// update.json and the API's release both become { version, notes, page, assets }.
|
||||
function fromManifest(m) {
|
||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
const version = String(m.version).replace(/^v/i, "");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
|
||||
return { version, notes: String(m.notes || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
// Assets always come from this repository's release, whatever the manifest says.
|
||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||
return { version, notes: String(r.body || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
+61
-6
@@ -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
|
||||
|
||||
+11
-4
@@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
|
||||
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
|
||||
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
|
||||
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
|
||||
stops working once it's published. From then on, running copies see the update. They check about 8
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
|
||||
@@ -249,6 +249,10 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
|
||||
and keeps the current refresh rate when SteamVR refuses a requested one.
|
||||
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
|
||||
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
|
||||
If this copy of Frame Control is missing the layer's library (an incomplete
|
||||
install, or a file removed after installing), VR apps install without it and
|
||||
the install message says so; OpenXR 1.0 apps still run. Release builds fail
|
||||
to package without it (`app/build/check-resources.js`).
|
||||
|
||||
Verified on the headset (2026-09-28):
|
||||
|
||||
|
||||
@@ -190,8 +190,11 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
|
||||
|
||||
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
|
||||
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
|
||||
Its SHA-256 is recorded below with the APK checks.
|
||||
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
|
||||
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
|
||||
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
|
||||
extractor skips. Frame Control decompresses it when injecting the layer. The
|
||||
SHA-256 of the uncompressed library is recorded below with the APK checks.
|
||||
|
||||
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
|
||||
and the actual layer with a fake next layer and host-only log/JNI shims.
|
||||
|
||||
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
|
||||
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build "$here/build-android"
|
||||
mkdir -p "$here/prebuilt/arm64-v8a"
|
||||
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
|
||||
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
|
||||
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
|
||||
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
|
||||
# can't decode, so the installed app silently lacked the library.
|
||||
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
|
||||
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
|
||||
Binary file not shown.
Binary file not shown.
+74
-16
@@ -3,23 +3,65 @@
|
||||
# (docs/releasing.md). Checks every installer is attached with a SHA-256
|
||||
# digest first, since the app's updater refuses assets without one, then
|
||||
# attaches update.json, the manifest the updater reads.
|
||||
# Usage: scripts/publish-release.sh v0.4.0
|
||||
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
|
||||
#
|
||||
# Everything goes through the REST API (gh api), not `gh release view/edit`:
|
||||
# those look a draft up by its tag, and a draft can show tag_name
|
||||
# "untagged-..." until it's published, so they report "release not found"
|
||||
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
|
||||
# link that dies on publishing, so update.json's page is built from the tag.
|
||||
set -eu
|
||||
tag="${1:?usage: $0 vX.Y.Z}"
|
||||
dry=""
|
||||
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
|
||||
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
|
||||
repo=saphid/frame-control
|
||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
||||
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
||||
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
||||
page="https://github.com/$repo/releases/tag/$tag"
|
||||
|
||||
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
|
||||
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
|
||||
# the default branch, which isn't what was built and tested.
|
||||
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|
||||
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
|
||||
|
||||
# Every release, drafts included, one JSON object per line.
|
||||
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
|
||||
# The release whose tag_name is the tag; failing that, the one untagged-... draft
|
||||
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
|
||||
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
|
||||
import json, re, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
rels = [json.loads(line) for line in open(path) if line.strip()]
|
||||
hits = [r for r in rels if r.get("tag_name") == tag]
|
||||
if not hits:
|
||||
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
|
||||
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
|
||||
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
|
||||
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
|
||||
and title.fullmatch(r.get("name") or "")]
|
||||
if len(hits) != 1:
|
||||
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
|
||||
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
||||
r = hits[0]
|
||||
if not r.get("draft"):
|
||||
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
|
||||
json.dump(r, sys.stdout)
|
||||
EOF
|
||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
|
||||
|
||||
missing=""
|
||||
for name in $expected; do
|
||||
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin); n=sys.argv[1]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
|
||||
digest=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1])); n=sys.argv[2]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
@@ -30,16 +72,32 @@ done
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
names=set(sys.argv[1].split())
|
||||
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
|
||||
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
|
||||
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
|
||||
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
|
||||
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))
|
||||
EOF
|
||||
old=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
|
||||
|
||||
if [ -n "$dry" ]; then
|
||||
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
|
||||
cat "$tmp/update.json"
|
||||
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for asset in $old; do
|
||||
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
||||
done
|
||||
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||
echo "ok update.json"
|
||||
|
||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
||||
echo "published $tag; running copies will offer it at their next check"
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||
echo "running copies will offer $tag at their next check"
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
|
||||
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
|
||||
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
|
||||
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
|
||||
Anything else fails, so the script under test can't fall back to `gh release ...`
|
||||
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
|
||||
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
|
||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||
(raw strings, compact JSON)."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
f.write(json.dumps(args) + "\n")
|
||||
if not args or args[0] != "api":
|
||||
sys.exit("fakegh: only `gh api` is supported: %r" % args)
|
||||
|
||||
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "-X":
|
||||
method = args[i + 1]; i += 2
|
||||
elif a in ("-f", "-F"):
|
||||
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
|
||||
elif a == "--input":
|
||||
inp = args[i + 1]; i += 2
|
||||
elif a == "--jq":
|
||||
jq = args[i + 1]; i += 2
|
||||
elif a == "-H":
|
||||
i += 2
|
||||
elif a.startswith("-"):
|
||||
i += 1
|
||||
elif endpoint is None:
|
||||
endpoint = a; i += 1
|
||||
else:
|
||||
sys.exit("fakegh: unexpected argument %r" % a)
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
|
||||
|
||||
def answer(response, fallback):
|
||||
"""Print response through --jq when jq is here, else the fixed fallback text."""
|
||||
if jq and shutil.which("jq"):
|
||||
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
sys.stdout.write(out.stdout)
|
||||
if out.returncode:
|
||||
sys.exit("fakegh: jq failed: " + out.stderr)
|
||||
else:
|
||||
print(fallback)
|
||||
|
||||
|
||||
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||
tag = endpoint.rsplit("/", 1)[1]
|
||||
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
# The fallback is what --jq '.[]' prints.
|
||||
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||
elif method == "POST" and endpoint == repo + "releases":
|
||||
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
|
||||
"name": fields.get("name"), "draft": fields.get("draft") == "true",
|
||||
"body": fields.get("body"), "assets": [],
|
||||
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
|
||||
answer(new, json.dumps(new))
|
||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||
pass
|
||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
|
||||
dst.write(src.read())
|
||||
print("{}")
|
||||
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||
rid = endpoint[len(repo + "releases/"):]
|
||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
|
||||
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
|
||||
"published %s at %s" % (fields.get("tag_name"), page))
|
||||
else:
|
||||
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
|
||||
@@ -256,6 +256,36 @@ class Repositories(unittest.TestCase):
|
||||
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
|
||||
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
|
||||
|
||||
def test_per_abi_builds_offer_and_download_the_arm64_one(self):
|
||||
import hashlib
|
||||
|
||||
def build(code, name, abis):
|
||||
self.files[name] = name.encode()
|
||||
return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
|
||||
'nativecode': abis},
|
||||
'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
|
||||
'added': 0}
|
||||
# One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
|
||||
# plus a universal and an arm64-only build sharing a code.
|
||||
builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
|
||||
build(3913, 'app-x86.apk', ['x86']),
|
||||
build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
|
||||
build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
|
||||
raw = self.root / 'splits.json'
|
||||
raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
|
||||
'metadata': {'name': {'en-US': 'Grayjay'}},
|
||||
'versions': {str(i): b for i, b in enumerate(builds)}}}}))
|
||||
source = {'id': 'test', 'url': URL}
|
||||
app = fdroid._reduce(raw, source)['com.futo.platformplayer']
|
||||
self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
|
||||
self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
|
||||
with patch.object(fdroid, 'details', return_value=app):
|
||||
for code in (None, 3912):
|
||||
fdroid.download(source, 'com.futo.platformplayer', version_code=code)
|
||||
self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
|
||||
with self.assertRaises(SourceError): # the x86_64 build is never offered
|
||||
fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
|
||||
|
||||
def test_v2_legacy_screenshot_keys_and_limit(self):
|
||||
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
|
||||
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
|
||||
|
||||
@@ -184,6 +184,35 @@ class VersionsTest(unittest.TestCase):
|
||||
info['abis'] = ['armeabi-v7a']
|
||||
self.assertIn('no arm64-v8a build', versions.describe(info))
|
||||
|
||||
def test_wrong_abi_error_says_which_file_to_get(self):
|
||||
import frame_telemetry
|
||||
for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried
|
||||
info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis}
|
||||
with self.assertRaises(frame_android.FrameError) as error:
|
||||
frame_android.check_installable(info)
|
||||
message = str(error.exception)
|
||||
self.assertIn('no arm64-v8a build (%s)' % abis[0], message)
|
||||
self.assertIn('download the APK marked arm64-v8a', message)
|
||||
self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi')
|
||||
frame_android.check_installable({'label': 'Universal', 'min_sdk': 28,
|
||||
'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']})
|
||||
|
||||
def test_wrong_file_reports_do_not_rate_the_app(self):
|
||||
reports = frame_catalog.reports
|
||||
wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed',
|
||||
'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only',
|
||||
'date': '2026-10-01T10:00:00'}
|
||||
self.assertIsNone(reports.verdict([wrong_file]))
|
||||
app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file])
|
||||
self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands
|
||||
# A real installer failure, a crash or a person's rating still counts.
|
||||
installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK')
|
||||
self.assertEqual(reports.verdict([installer])[0], 'no')
|
||||
crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02')
|
||||
self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no')
|
||||
rated = dict(wrong_file, rating='works', date='2026-10-03')
|
||||
self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works')
|
||||
|
||||
def test_install_resolves_index_hash(self):
|
||||
versions.alternatives('org.example.app')
|
||||
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
|
||||
|
||||
@@ -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')):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
|
||||
through the REST API even when its tag_name still says untagged-..., refuses
|
||||
missing installers or digests, writes update.json's page from the tag, and
|
||||
publishes with one PATCH. Nothing here talks to GitHub.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "publish-release.sh"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
|
||||
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
|
||||
"Frame-Control-linux-arm64.deb"]
|
||||
|
||||
|
||||
def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
|
||||
if assets is None:
|
||||
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
|
||||
for i, n in enumerate(INSTALLERS)]
|
||||
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
|
||||
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
|
||||
"assets": assets}
|
||||
|
||||
|
||||
class PublishRelease(unittest.TestCase):
|
||||
def run_script(self, releases, *args, tags="v9.8.7"):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
|
||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||
return p, log, upload
|
||||
|
||||
def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
|
||||
old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
|
||||
rel = draft(assets=draft()["assets"] + [old])
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
|
||||
self.assertEqual(upload["version"], "9.8.7")
|
||||
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
|
||||
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
|
||||
patch = next(c for c in log if "PATCH" in c)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
|
||||
self.assertIn(f, patch)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
|
||||
|
||||
def test_an_untagged_draft_may_carry_a_subtitle(self):
|
||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(upload["version"], "9.8.7")
|
||||
|
||||
def test_prefers_the_release_whose_tag_name_matches(self):
|
||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
|
||||
draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
|
||||
|
||||
def test_refuses_missing_or_unhashed_installers(self):
|
||||
assets = draft()["assets"][1:]
|
||||
assets[0] = dict(assets[0], digest=None)
|
||||
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
|
||||
self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
|
||||
|
||||
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
|
||||
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("2 releases", p.stderr)
|
||||
p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("no release", p.stderr)
|
||||
# A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
|
||||
for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
|
||||
draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0, rel)
|
||||
self.assertIn("no release", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("PATCH" in c for c in log))
|
||||
p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("push it first", p.stderr)
|
||||
|
||||
def test_dry_run_changes_nothing(self):
|
||||
p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
|
||||
self.assertIsNone(upload)
|
||||
self.assertTrue(all("-X" not in c for c in log))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
|
||||
the workflow and run with bash (as Actions runs it) against a stand-in gh
|
||||
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
|
||||
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
|
||||
found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to
|
||||
GitHub.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
STEP = "- name: Create the draft release"
|
||||
|
||||
|
||||
def step_script():
|
||||
"""The step's `run: |` block, dedented."""
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
|
||||
while lines[i].strip() != "run: |":
|
||||
i += 1
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
body = []
|
||||
for line in lines[i + 1:]:
|
||||
if line.strip() and len(line) - len(line.lstrip()) <= indent:
|
||||
break
|
||||
body.append(line)
|
||||
return textwrap.dedent("\n".join(body)).strip() + "\n"
|
||||
|
||||
|
||||
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
|
||||
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
|
||||
|
||||
|
||||
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
|
||||
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
|
||||
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
|
||||
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
|
||||
class DraftStep(unittest.TestCase):
|
||||
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "step.sh").write_text(step_script())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
|
||||
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
|
||||
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
return p, log
|
||||
|
||||
def posts(self, log):
|
||||
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
|
||||
|
||||
def patches(self, log):
|
||||
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
|
||||
|
||||
def test_new_draft_uses_the_id_the_post_returns(self):
|
||||
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
|
||||
p, log = self.run_step([])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
[post] = self.posts(log)
|
||||
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
|
||||
self.assertIn(f, post)
|
||||
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
|
||||
self.assertIn("tag_name=v9.8.7", patch)
|
||||
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
|
||||
|
||||
def test_a_rerun_reuses_the_draft_with_the_tag(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
|
||||
def test_other_releases_alone_mean_a_new_draft(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
|
||||
release(8, "v9.8.70", "Frame Control 9.8.70")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(len(self.posts(log)), 1)
|
||||
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
|
||||
|
||||
def test_stops_when_the_tag_is_not_on_github(self):
|
||||
p, log = self.run_step([], tags="")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
self.assertEqual(self.patches(log), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+72
-9
@@ -10,6 +10,7 @@ import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
@@ -287,15 +288,77 @@ class OneServer(unittest.TestCase):
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
|
||||
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
|
||||
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid"}
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
|
||||
and later, now and then, crash it (SIGSEGV) while background threads were still
|
||||
loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
|
||||
for attempt in range(5):
|
||||
with self.subTest(attempt=attempt):
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
|
||||
|
||||
def test_staging_folders_cleared_when_stopped_mid_transfer(self):
|
||||
"""The server leaves without interpreter teardown, so TemporaryDirectory cleanup
|
||||
doesn't run for work still in progress: its own staging folders go on the way out,
|
||||
and a dead server's at the next start."""
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
home = tempfile.mkdtemp(prefix="frame-stop-home-")
|
||||
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
|
||||
"FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
|
||||
"LOCALAPPDATA": home, "APPDATA": home}
|
||||
index_dir = Path(subprocess.run(
|
||||
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
|
||||
"print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
|
||||
env=env, capture_output=True, text=True, check=True).stdout.strip())
|
||||
index_dir.mkdir(parents=True)
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
|
||||
def staged(folder, prefix, pid):
|
||||
d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
|
||||
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
|
||||
(d / "app.apk").write_bytes(b"\0" * 4096)
|
||||
return d
|
||||
|
||||
# Stopped as the app stops it: by closing stdin (the only way on Windows,
|
||||
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
|
||||
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
|
||||
with self.subTest(stop=stop):
|
||||
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
|
||||
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
self.assertIn("Frame Control on", proc.stdout.readline())
|
||||
self.assertEqual([d for d in left_by_dead if d.exists()], [])
|
||||
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
|
||||
staged(index_dir, ".download-", proc.pid)]
|
||||
if stop == "stdin":
|
||||
proc.stdin.close()
|
||||
else:
|
||||
proc.terminate()
|
||||
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
|
||||
self.assertEqual([d for d in in_flight if d.exists()], [])
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
proc.stdin.close()
|
||||
proc.stdout.close()
|
||||
|
||||
|
||||
class ArtworkSettings(unittest.TestCase):
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-11
@@ -386,17 +386,28 @@ class ServerJobs(unittest.TestCase):
|
||||
def test_dead_servers_leftovers_swept(self):
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
# Downloads and title staging (unzipped titles) are both swept.
|
||||
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
|
||||
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
|
||||
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
|
||||
try:
|
||||
self.server.sweep_tmp()
|
||||
self.assertFalse(os.path.exists(gone), prefix)
|
||||
self.assertTrue(os.path.exists(live), prefix)
|
||||
finally:
|
||||
shutil.rmtree(gone, ignore_errors=True)
|
||||
shutil.rmtree(live, ignore_errors=True)
|
||||
s = self.server
|
||||
with tempfile.TemporaryDirectory() as cache, \
|
||||
mock.patch.object(s.frame_host, "cache_dir", lambda *parts: Path(cache).joinpath(*parts)):
|
||||
# Downloads, title staging, patched VR APKs, files staged for the
|
||||
# assistant, and app-index downloads (in the cache folder).
|
||||
places = s._tmp_places()
|
||||
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
|
||||
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
|
||||
s.apk_fdroid.TMP_PREFIX})
|
||||
for folder, prefix in places:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
|
||||
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-", dir=folder)
|
||||
try:
|
||||
s.sweep_tmp()
|
||||
self.assertFalse(os.path.exists(gone), prefix)
|
||||
self.assertTrue(os.path.exists(live), prefix)
|
||||
s.sweep_tmp(own=True) # on the way out: this server's own go too
|
||||
self.assertFalse(os.path.exists(live), prefix)
|
||||
finally:
|
||||
shutil.rmtree(gone, ignore_errors=True)
|
||||
shutil.rmtree(live, ignore_errors=True)
|
||||
|
||||
def test_temp_dir_failure_ends_the_job(self):
|
||||
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
|
||||
|
||||
@@ -27,6 +27,7 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
|
||||
|
||||
KIND = 'fdroid'
|
||||
CACHE_VERSION = 2
|
||||
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
_LOCK = threading.RLock() # settings only; never held while downloading
|
||||
_load_locks = {}
|
||||
_refreshing = {} # source id -> background refresh thread
|
||||
@@ -530,7 +531,7 @@ def _load(source, force=False):
|
||||
return found[:2]
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
|
||||
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
|
||||
v2 = True
|
||||
try:
|
||||
|
||||
+3
-1
@@ -9,6 +9,8 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
TMP_PREFIX = 'frame-agent-' # then the server's PID, so server.sweep_tmp can clear a stopped run's
|
||||
|
||||
|
||||
class Approvals:
|
||||
def __init__(self):
|
||||
@@ -109,7 +111,7 @@ def call(server, body):
|
||||
if name == 'send_file':
|
||||
# Stage the reviewed bytes before the existing transfer helper reads them.
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
|
||||
source = Path(action['arguments']['path'])
|
||||
with source.open('rb') as stream:
|
||||
data = stream.read(16 * 1024**2 + 1)
|
||||
|
||||
+54
-13
@@ -11,7 +11,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
import base64, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
@@ -23,6 +23,7 @@ from frame_apk_sign import repack
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
|
||||
TMP_PREFIX = 'frame-vr-' # then the server's PID, so server.sweep_tmp can clear a stopped run's patched APK
|
||||
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
|
||||
COMPAT = '.local/share/Steam/steamapps/compatdata'
|
||||
SHADERS = '.local/share/Steam/steamapps/shadercache'
|
||||
@@ -33,7 +34,8 @@ SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
|
||||
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
|
||||
XR_COMPAT_FILES = {
|
||||
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
|
||||
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
|
||||
# Gzipped in the repo and the app; see frame/openxr-compat/build.sh for why.
|
||||
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz',
|
||||
}
|
||||
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
|
||||
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
|
||||
@@ -43,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.
|
||||
@@ -50,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
|
||||
|
||||
|
||||
@@ -94,17 +110,29 @@ def xr_compat_files(apk_path):
|
||||
for entry, rel in XR_COMPAT_FILES.items():
|
||||
try:
|
||||
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
|
||||
add[entry] = f.read()
|
||||
except OSError:
|
||||
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
|
||||
data = f.read()
|
||||
add[entry] = gzip.decompress(data) if rel.endswith('.gz') else data
|
||||
except (OSError, EOFError, zlib.error): # gzip.BadGzipFile is an OSError
|
||||
add[entry] = b''
|
||||
if not add[entry]: # missing, unreadable (antivirus, permissions) or truncated
|
||||
raise LayerMissing(LAYER_MISSING)
|
||||
return add
|
||||
|
||||
|
||||
def layer_note(meta):
|
||||
"""The missing-layer warning for an install's completion message, or ''."""
|
||||
return LAYER_MISSING_NOTE if LAYER_MISSING_NOTE in ((meta or {}).get('vr_issues') or []) else ''
|
||||
|
||||
|
||||
def check_installable(info):
|
||||
if info['min_sdk'] and info['min_sdk'] > 30:
|
||||
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
|
||||
if info['abis'] and 'arm64-v8a' not in info['abis']:
|
||||
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
|
||||
# Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64,
|
||||
# universal) leave the choice to the user; say which file to fetch instead.
|
||||
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. "
|
||||
"This file is for other devices: download the APK marked arm64-v8a "
|
||||
"(or arm64, or universal) and install that instead")
|
||||
|
||||
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
@@ -159,18 +187,31 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
if flatscreen is None:
|
||||
flatscreen = not info['vr']
|
||||
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
|
||||
# changes calls SteamVR would otherwise reject.
|
||||
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
|
||||
# changes calls SteamVR would otherwise reject. Without it OpenXR 1.0 apps
|
||||
# still run, so a copy of Frame Control that lacks it installs anyway and
|
||||
# says so, unless the layer was asked for explicitly.
|
||||
add = {}
|
||||
if info['vr'] if xr_compat is None else xr_compat:
|
||||
try:
|
||||
add = xr_compat_files(apk_path)
|
||||
except LayerMissing:
|
||||
if xr_compat:
|
||||
raise
|
||||
info['vr_issues'] = list(info.get('vr_issues') or []) + [LAYER_MISSING_NOTE]
|
||||
info['xr_layer_missing'] = True
|
||||
with _install_lock:
|
||||
if add or info['repairable']:
|
||||
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
|
||||
patched = os.path.join(tmp, 'app.apk')
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
try:
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
except FrameError as e:
|
||||
raise FrameError(f'could not prepare the APK for the Frame: {e}') from e
|
||||
info['launchable'] = True
|
||||
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
|
||||
else:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
|
||||
except FrameError as e:
|
||||
except Exception as e: # not only FrameError: every failed install is reported
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
_after_install(info, meta, None, start)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+60
-15
@@ -45,6 +45,7 @@ import frame_agent # noqa: E402
|
||||
import frame_assistant # noqa: E402
|
||||
import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
from apk_sources import fdroid as apk_fdroid # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
@@ -337,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):
|
||||
@@ -346,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
|
||||
|
||||
@@ -1255,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"):
|
||||
@@ -1287,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)
|
||||
|
||||
|
||||
@@ -1304,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"))
|
||||
@@ -1424,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']}"}
|
||||
|
||||
|
||||
@@ -1844,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)
|
||||
@@ -1922,23 +1936,36 @@ def _pid_alive(pid):
|
||||
return True
|
||||
|
||||
|
||||
def sweep_tmp():
|
||||
"""Delete download and title staging folders left by a server killed mid-install.
|
||||
def _tmp_places():
|
||||
"""Where each kind of staging folder goes, and its name before the server's PID."""
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
|
||||
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
|
||||
|
||||
Folders carry the server's PID, so only a dead server's are taken.
|
||||
|
||||
def sweep_tmp(own=False):
|
||||
"""Delete staging folders (downloads, unzipped titles, patched APKs, staged files,
|
||||
app-index downloads) left by a server that stopped mid-way.
|
||||
|
||||
Folders carry the server's PID, so only a dead server's are taken; own=True (on
|
||||
the way out, see main) takes this server's too.
|
||||
"""
|
||||
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
|
||||
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
|
||||
_sweep_one(prefix, d)
|
||||
for folder, prefix in _tmp_places():
|
||||
try:
|
||||
found = list(folder.glob(f"{prefix}*"))
|
||||
except OSError:
|
||||
continue
|
||||
for d in found:
|
||||
_sweep_one(prefix, d, own)
|
||||
|
||||
|
||||
def _sweep_one(prefix, d):
|
||||
def _sweep_one(prefix, d, own=False):
|
||||
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
|
||||
if not m:
|
||||
return
|
||||
pid = int(m[1])
|
||||
try:
|
||||
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
|
||||
if (own if pid == os.getpid() else not _pid_alive(pid)) and d.is_dir():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -2710,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))
|
||||
@@ -2742,6 +2771,22 @@ def main():
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
_purge_titles(now=float("inf")) # unconfirmed title uploads
|
||||
# Staging folders of work still running (a patched APK mid-copy, an index
|
||||
# download): leaving below skips the cleanup their TemporaryDirectory would
|
||||
# get at interpreter exit. sweep_tmp at the next start catches any missed.
|
||||
sweep_tmp(own=True)
|
||||
# Stopped as asked, and everything above is cleaned up. Leave now, without
|
||||
# Python's interpreter teardown: daemon threads are still running (app index
|
||||
# downloads, the stdin watcher, telemetry, the headset link, request handlers)
|
||||
# and none can be stopped promptly. Tearing the interpreter down under them
|
||||
# occasionally crashed the process (SIGSEGV, seen on Python 3.13 on Linux):
|
||||
# OpenSSL's exit cleanup freed state those threads were using. That teardown
|
||||
# also runs atexit handlers and weakref finalizers; nothing here registers
|
||||
# atexit work, the only finalizers are TemporaryDirectory cleanups (swept
|
||||
# above), and the OS frees the one-server lock with the process.
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user