Compare the deadline passed on with the caller's own deadline instead of a
fresh time.monotonic(), which failed on windows-latest with
5.000000000000028 > 5 (CI run 38200136884).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The duplicate-name check now compiles each page's scripts together as one
module with node --check, where a top-level name declared twice is a
SyntaxError. Node's parser handles nested scopes, comments and strings,
which the old line scanner did not. Fixture tests show it catches same-line,
cross-block and const/function duplicates and ignores nested ones.
- The live view's loadPanels no longer logs expected offline or stale poll
failures (as refresh() does).
- After a render failure, openStoreApp skips the details request, and
loadOfferDetails no longer writes to a missing #detailInstallNote, which
threw an unhandled TypeError. store_detail.mjs now covers the full open flow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking any app in the Android tab opened an empty dialog, which shows as
a single horizontal line (its 1px border). The store's renderDetail was
replaced by the Devices page's renderDetail, declared further down the same
script: a later function declaration silently wins. The store's call then
rendered "Pick a headset." into the Devices panel and left #detailBody empty.
Every platform and both 0.4.x releases were affected.
The store's renderer is now renderAppDetail, and a render failure shows a
clear message with a Close button rather than an empty dialog. The same
clash hid the live view's loadPanels behind the Tools panel switcher's; that
one is now loadPanelSwitcher. Both names match the open store PR (#56).
Tests: a guard that no top-level name is declared twice in the page's
shared script scope, and tests/page/store_detail.mjs, which runs the real
store functions for a good listing and a broken one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.
tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Windows, terminate() is TerminateProcess (a hard kill, exit 1, no cleanup);
the app stops the server there by closing stdin. The staging-folder test now
stops it by closing stdin on every platform, and also by SIGTERM off Windows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-4 review (pr79-review-r4), opt-in log only:
- The headset's own names are replaced whole (longest first, any case) before
ssh's hostname/host/to operands, so "talking to Jane Doe's work headset"
can't be cut to "<host> Doe's work headset".
- Only the scrubbers' own tokens (<host>, <user>, <ip>, ...) are exempt from
name replacement, so a display name like "<Jane Doe>" goes too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Leaving through os._exit skips the weakref finalizers that clean up a
TemporaryDirectory still in use by a background thread, so quitting during
a patched VR APK transfer left the APK behind (and likewise a file staged
for the assistant, or a half-downloaded app index in the cache folder).
Those folders now carry the server's PID (frame-vr-, frame-agent-, and
.download- in the apk-sources cache folder), like the web-install and title
staging folders already did. sweep_tmp covers all five; it still runs at
start for dead servers' folders, and with own=True at the end of the
shutdown cleanup for this server's. The exit comment now says which exit
work is skipped and why that is safe.
Tests: the sweep test covers every prefix and own=True; a new server test
stops a server with in-flight folders and checks they are gone, and that a
dead server's are removed at the next start.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-3 review (pr79-review-r3), opt-in log only:
- Each attempt's redaction set now includes the headset's ssh alias and
display name (any case), and "has no addresses" no longer names it.
- ssh's hostname/host/to operands are redacted before the known names, known
names never replace inside an existing <placeholder>, and names that are
ssh's own words (host, hostname, to, port) aren't treated as names.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 4 of #78: /api/android and /api/titles re-raise frame_android's
FrameError as Failure(str(e)), which dropped frame_link_failed, so their
connection failures were still reported after the status poll's first one.
Both now raise ... from e, and diagnostic() follows __cause__
(frame_telemetry.link_failed). server.ssh() judges a link failure on ssh's
stderr only, never the command's stdout.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Linux with Python 3.13 (GitHub's ubuntu-latest runner, CPython 3.13.16),
about 1 stop in 100 crashed the server with SIGSEGV. A native backtrace
taken at the crash shows background threads inside OpenSSL
(X509_STORE_set_default_paths_ex, called from _ssl while the app-index
download threads build their TLS context) while the main thread was already
in exit(), where libcrypto's own atexit cleanup frees the state those
threads are using.
After the shutdown cleanup has run, the server now flushes stdout/stderr and
calls os._exit(0). Daemon threads (index downloads, stdin watcher,
telemetry, contact, headset link, request handlers) cannot be stopped
promptly, and nothing in the server registers atexit work; the OS frees the
one-server lock with the process. The stop test now runs its scenario five
times with faulthandler on.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 3 of #78: matching ssh's words in the message could still be
spoofed by a download's file name ("Connection closed by frame port 22.zip"
in a checksum failure lost its $exception after a poll timeout). Now
server.ssh and frame_android.ssh mark the exception (frame_host.link_failure)
when ssh itself failed to reach the headset: a time out, or exit 255 with a
line ssh starts (frame_host.ssh_link_failed). diagnostic() holds back only
marked errors in frame_unreachable / frame_not_set_up, once per session;
everything else keeps the 10-minute window. Web-link download and checksum
failures are classified download_failed before any other category.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-2 review (pr79-review-r2):
- The summary names the alias only as default ("frame") or custom, and the
~/.ssh/config line says "for the active alias" without naming it.
- ssh -V is parsed only as a banner at the start (OpenSSH_N.N[pN], optional
LibreSSL/OpenSSL N.N.N) and rebuilt from fixed names and numbers; anything
else is "unknown".
- scrub: ssh's whole user@host field (spaces, DOMAIN\ prefix, full domain
names, "user@host's password:") goes before the email rule; a home folder's
whole name runs to the next separator (apostrophes too), prefixes matched
whatever their case.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Independent review (pr79-review-r1) found leaks in the first version: a
failure's raw text was hidden with the current headset's hosts (so switching
headsets let the old one's name through), unknown hosts and other cases
survived, Windows user names with spaces partly survived, the PATH scan ran on
every preview, and the log throttle only caught consecutive repeats.
- The always-on summary has no free text: the current error and last failure
are a stage plus the telemetry error category (decided when the failure
happens) and how long ago. ssh -V is reduced to its version words.
- The opt-in server log line is scrubbed when written, with that attempt's
hosts (case-insensitive), any name ssh gives after hostname/host/to, and
then the shared scrubber.
- frame_telemetry.scrub: a Windows home folder's whole name (spaces too) and
the whole user part of ssh's user@host (spaces, DOMAIN\user) become <user>.
- The ssh discovery runs once in the background from server start; a report
waits at most 0.3 s for it.
- The log throttle keeps per-failure timestamps and counts, bounded to 32.
- Regression tests for each case; privacy.md says exactly what's sent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review round 2 of #78: FRAME_LINK_FAILED matched its words anywhere, so a
web-link checksum mismatch for a file named kex_exchange_identification.zip,
after a status-poll timeout, lost its $exception. Each pattern is now
anchored to the start of a line as ssh prints it (re.M), and our own
"Timed out talking to HOST" must be the whole line.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #78: the once-per-session hold applied to anything classified
frame_unreachable, so after one status-poll timeout a web-link download whose
connection reset ("download failed: ... Connection reset by peer") lost its
$exception. Now the hold needs ssh's own wording (connect to host, could not
resolve, timed out talking to, banner exchange, mux/client_loop, connection
closed/reset by HOST port N); everything else keeps the 10-minute window.
Web-link download failures are download_failed. A bare "ssh exited 255" is
no longer frame_unreachable: the command on the Frame may have exited 255.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review pr75-review-r2: _webinstall_run catches non-FrameError APK install
failures without a diagnostic, and apk_installed now leaves those to the
caller, so they went unreported. Report them there; test the full worker.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review findings on PR #75 (pr75-review-r1):
- Catalogue, alternate-version and web-link installs now append the
"installed without the OpenXR layer" warning to their completion message
(frame_android.layer_note); dropped files already showed vr_issues.
- A non-FrameError that install() re-raises is reported by whoever catches
it (job or request handler, with its traceback), so apk_installed sends
install_finished without its own diagnostic for it (install_finished
diagnose=False). Still exactly one install_finished.
- Tests run the real job path for both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- publish-release.sh: the fallback takes only an untagged-... draft titled
exactly "Frame Control X.Y.Z" (optionally ": subtitle"); a pre-release's
draft ("9.8.7-rc.1") or one bound to another tag is refused.
- updater.js: ignore the page in update.json/the API entirely and always
link to releases/tag/v<version> built from the validated version, so a
path like tag/..\..\other/repo can't reach shell.openExternal.
- Tests for both, including backslash and %2e%2e traversal.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
electron-builder's 7-Zip compresses a bare arm64 ELF with its ARM64 branch
filter (method 0A), which the NSIS installer's extractor can't decode and
silently skips. Every installed Windows copy (0.4.0, 0.4.1) lacked
libXrApiLayer_FRAME_compat.so, so VR APK installs failed instantly.
- Commit and package the library as libXrApiLayer_FRAME_compat.so.gz
(byte-identical when decompressed; SHA-256 checked against the README);
frame_android decompresses it, and a corrupt file counts as missing.
- build.sh writes the .gz.
- check-resources.js also runs from the command line against an installed
copy, comparing every file with the unpacked build.
- release.yml: the Windows job installs the NSIS installer silently and runs
that check.
- Tests: no bare ELF under prebuilt/, packaging filter ships only the .gz.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review (T3 task pr74-review-r1, gpt-6.1-sol) found that search blocked on
frame_compat_db.load() after a cache expiry (up to ~40 s with an unreachable
database, or an untimed Keychain lookup without a key), and that the verdict
showed only in the detail view, not on cards. Revert ui/apk_sources/search.py,
ui/server.py, ui/index.html and their tests to main; keep the wrong-ABI error
text, the reports.py wrong-file filter and the ABI-selection tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
publish-release.sh took update.json's "page" from the draft's url, which is
releases/tag/untagged-... and dies on publishing; 0.4.0's manifest shipped
that dead link. It also looked the draft up with `gh release view <tag>`,
which reports "release not found" while a draft's tag_name still reads
untagged-... (and uses rate-limited GraphQL).
- publish-release.sh: REST only. Checks the tag exists, finds the release by
tag_name or else the one untagged draft titled "Frame Control X.Y.Z",
keeps the 8-installer digest check, replaces update.json via the uploads
API, then PATCHes tag_name/draft/prerelease/make_latest. Adds --dry-run.
- updater.js: trust only this repo's releases/tag/<tag> pages (never
untagged-...); otherwise link to releases/tag/v<version>.
- release.yml: one draft job before the matrix (no racing creates), with
--verify-tag and tag_name set explicitly.
- Tests: tests/test_publish_release.py with a stand-in gh (tests/fakegh/gh),
and an updater test for the page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three Windows reports said "ssh frame works in the terminal, but the app can't
find the headset", and their diagnostics held only versions: activity and the
server log are opt-in, and the connector never logged its failures anyway.
- frame_report.diagnostics always adds a connection summary (under "Include
diagnostics"): connector phase, failed stage, attempt and reason; headset
count and active alias; the current error and last failure plus ssh's last
line, with the headset's addresses/hosts and user@ hidden and then the usual
scrub; each address tried as its kind only (.local, ipv4, ipv6 link-local,
tailscale, hostname, alias, and what a name resolved to) with its probe
state; gateway/Tailscale; the ssh the app runs by kind (never its path),
`ssh -V`, other ssh kinds on PATH; and whether ~/.ssh/config has the managed
block and a hand-written Host for the alias.
- frame_link prints each failed attempt (stage, message, ssh's last line,
address kinds) to stderr, scrubbed, so it lands in server.log; a failure that
repeats every retry is written at most every 5 minutes.
- docs/privacy.md and the report dialog say exactly what the summary contains.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- frame_android: a missing, unreadable or empty OpenXR compat layer no longer
aborts a VR install; it installs without the layer and says so (vr_issues),
unless the layer was asked for explicitly. Patch failures are named, and any
exception (not only FrameError) reaches the install hooks/telemetry.
- app/build/check-resources.js (electron-builder afterPack): the build fails if
the layer's arm64-v8a library or other required resources are missing.
- frame_telemetry: new fixed error categories layer_missing, apk_repack_failed,
tool_missing; ok=false without an error reports "other".
- server: install_finished carries xr_layer_missing when a VR APK installed
without the layer. docs/privacy.md lists every category and the new field.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PostHog (0.4.0): Grayjay installs failed twice with apk_wrong_abi, once with
an armeabi-v7a file and once with an x86_64 file. Grayjay's own site offers
one APK per ABI; the FUTO F-Droid repo's Grayjay 391 is universal and does
contain arm64-v8a, and the catalogue and repo search already drop builds
without arm64-v8a, so the wrong files did not come from those paths.
- The wrong-ABI error now tells the user to download the arm64-v8a (or
arm64, or universal) APK instead, so they don't guess again.
- An install_failed report caused by a wrong-ABI (or too-new-Android) file
no longer rates the whole app as broken in the catalogue.
- Search results and details carry the compatibility reports the catalogue
uses: "Reported not working on the Frame" (a warning, not a block) or
"Works on the Frame". A report never overrides a hard blocker.
- Tests: per-ABI split builds (offer and download the arm64 one), the new
error text and its telemetry category, wrong-file reports, and search
verdicts from reports, with the database hook off by default in tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The status poll (POST /api/comfort status) meets an asleep or absent headset
every few seconds, and each ssh failure became a $exception: ~760 in two weeks
from 0.4.0, nearly all connection timeouts. Errors whose category only means
the Frame couldn't be reached (frame_unreachable, frame_not_set_up) are now
sent at most once per category per session; other errors keep the 10-minute
per-message window.
The classifier now also files "Host is down", Windows' "Unknown error" and
"banner exchange: Connection to UNKNOWN port -1", "ssh exited 255", and
dropped shared connections (mux_client_, client_loop) as frame_unreachable
instead of "other".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- ~/.ssh/config writes swapped in a temp file that inherited the .ssh folder's
ACL; Windows' OpenSSH refuses one granting another account (even a deleted
one) more than read: "Bad owner or permissions". Writes now give the file an
owner-only ACL (frame_host.make_private), and the server repairs a refused
config once per run and retries.
- frame_link.probe named a link-local IPv6 zone with if_indextoname, which on
Windows is "ethernet_32769"; Windows' ssh can't resolve that, so a headset
found at fe80:: showed as "can't find the Frame". Use the zone number there.
- frame_connect.py imports frame_host (since #60), but the app runs it with
python -I, which leaves its folder off sys.path: Set Up Connection exited
with ModuleNotFoundError. Add the folder, as server.py does.
Verified on a Windows 11 VM against OpenSSH_for_Windows 9.5p2: the old write
reproduces the reported error with an orphan SID's Modify ACE; the new write,
repair and server retry all leave a config ssh accepts; ssh to %ethernet_32769
fails to resolve while %5 connects.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>