Main's Import Android game panel joins Quest and Meta under Apps > Browse,
and its Import button (androidBackupOpen, which lived in the old Android
store header and would have thrown at startup here) is now an Add / Install
choice. docs/android-backups.md points to the new places.
Home's shortcuts (Type on the Frame and the rest) now show their tool before
focusing it, so typing works on the first tap and phones open the keyboard
(Sol review P3). A tool panel already in its workspace isn't moved again, which
also dropped focus on the follow-up hashchange.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps the four primary destinations and ports main's newer work into them:
the Home "Right now" panel (volume, cast, quick actions) replaces the old
device block, whose readings now live in the header's headset popover; the
install queue sits above the Apps views; Import from my Quest and My Meta
library sit with Browse; the pad, panel switcher, Mac view and media panels
take main's markup. Scrolling follows main's #scroll container. Settings
stays a tab, so main's header gear is not added. The old .home-status
toggle is gone (main's startup test caught it touching a missing node).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed /
Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install
chooser for catalogs, Steam, Linux apps, files and install links. Installed
rows show identity, one state and Open/Stop; the rest moves to app details.
Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper
delivery and catalogue jobs stay bound to the headset they were meant for;
report preview follows the latest consent; failed Store reads are not an empty
library; keyboard access to device details and the Store sheet; the iOS
bundle ships the artwork renderer and OpenXR resources; typed addresses and
custom SSH ports survive setup; WebKit crashes recover; native preferences
survive relaunch; APK update rollback and repair stay retryable.
Refs #101, #104, #106
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Installs from the Android store, Import from my Quest and My Meta library
now go into one server-side queue and run one after another, instead of
being refused while another runs. The page shows the queue with Remove
for installs that haven't started; each queued install runs only on the
headset it was queued for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ui/store.html, scripts/store-on-frame.sh and the /store route (built in
another thread and left uncommitted in the main checkout) join this branch,
rebuilt on /api/search: the same sources, categories, phone-only rule and
artwork as the Store, as a 1280x800 laser panel. A category rail with
Installed, eight art cards a page, a featured VR app on Home, a detail
sheet with screenshots, install progress and game data, the thumbstick
turning pages and the on-screen keyboard.
- Artwork: the proxy allowed four name lookups at once and failed the rest
immediately, so a page of cards lost most of its images until the retry.
Public answers are now kept for five minutes and a lookup waits for a slot
within its own budget.
- Phones: one-line shelf headings, a shorter store header, wider shelf
cards, and a two-column library.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store browsed F-Droid alphabetically, so it opened on proxies, port
checkers and root tools. It now groups apps by what a headset is good for
(VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web &
chat, Tools) from F-Droid's own categories, hides phone-only apps unless
asked, and browses each source's best-illustrated, newest apps first.
- Store: a category sidebar (chips on narrow windows), one shelf per
category on the home page with no repeats, a featured VR app, and cards
that show a banner, phone screenshots side by side, or the icon over its
own blur instead of a letter, plus whether it fits the Frame.
- Library: its own view with art cards, Play, and a menu for Test, Report
and Remove; filters for VR, flat and running apps; display settings and
reports below. Artwork comes from the saved source indexes.
- Images: listings register three screenshots per app instead of twelve,
handles are indexed by URL, and the bound is 16,384, so a large browse no
longer forgets its own images before the page loads them.
- F-Droid cache version 3 keeps each app's categories.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for the sign-in window:
- Each sign-in has a random attempt id. Closing, timing out or signing out
stops cookie intake first, then tells the server (new cancel-signin action)
not to save a connect from that attempt, even one still waiting on Meta
(frame_meta.connect/cancel_signin; 409 from the server). Disconnect signs
out of the window before deleting the token.
- will-frame-navigate holds every frame to the allow-list; a frame going
elsewhere is stopped, never opened outside.
- The window is destroyed, not closed, and will-prevent-unload is overridden,
so beforeunload can't keep it alive; sign-out clears the session only after
the window is gone.
- Permission checks refused as well as requests; no developer tools.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in ui/frame_android.py where main added COPY_MIN_RATE (size-scaled
copy timeouts) beside the install lock this branch made reentrant: both kept,
the lock stays an RLock. ui/index.html and ui/server.py merged on their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A Frame error during a test (an ssh failure with empty stderr raises its
stdout, which can be the game's log) becomes the fixed "couldn't read the
game's log from the Frame" message, with no original text and no chained
cause. run_test wraps every step; the /api/meta test job also maps any
other exception to a fixed text, and the CLI and Install and test go
through run_test. Close failures no longer echo their error text.
- Log scans are linear: signatures look at the first 4 KB of each line and
every repetition followed by more required text is bounded; excerpts
split the redacted line once and fullmatch only words of 64 characters or
less; the generic OC/FRL token pattern matches each run once (no
lookahead) and keeps it unless it has a digit. Timing tests: 100 KB
adversarial lines through classify, redact and every excerpt each finish
well under 200 ms (the old library and token patterns took 19 s and 9 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opt-in, off until you connect: paste your own Meta access token, which is
checked with a read-only "who am I" query and kept owner-only in
data_dir/meta-account.json. Lists the Quest apps the account owns, downloads
an owned app's APK and OBB from Meta's download server into the install
cache (size limits, declared-size and optional SHA-256 checks, cleaned up
after every install), and installs it through Import from my Quest's checks,
install and two-step data copy. Install and test runs the game, classifies
the outcome from its log (runs, runs with issues, quits, crashes, hangs) with
a plain-English cause, and records it through the existing compatibility
report path with source meta-store and the version code, shared only per the
Privacy settings.
The token goes only to graph.oculus.com and securecdn.oculus.com over
HTTPS, is dropped on redirects elsewhere, and is redacted (any case,
percent-encoded, before truncation) from every message. A disconnect wins
over a connect in flight and stops downloads. No licence-check bypass.
Protocol facts come from the public QAVS, OculusGraphQLApiLib, DownQuest and
BSManager sources (docs/meta-library.md); the code is our own.
UI: Android -> My Meta library. CLI: ui/frame_meta.py. API: /api/meta.
Tested offline with a fake Meta and fake Frame (tests/test_meta.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An older Quest copy no longer replaces a newer install on the Frame: installs
now record versionCode, and a downgrade is refused unless allowed
(--allow-downgrade, "allowDowngrade", or the panel's checkbox).
- The game-data copy only targets this package's own install record and
instance at the Quest's exact version code, and checks Android's own
dumpsys in that instance before copying.
- After the (possibly long) pulls the target is checked again, and the final
checks, the transfer and closing the game hold the shared install lock,
now reentrant so install_obb and install can be called under it.
- Closing the game is confirmed; a failed or incomplete close is reported
instead of claiming the game was closed.
- Split members are read from their raw bytes: stored members must be exactly
their declared size, deflated streams must end exactly there, then the
checksum; forged sizes with prefix checksums are refused.
- A split's resource table counts as empty only if no type chunk defines any
entry (bags included) and every chunk is understood.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New ui/frame_quest.py (CLI: devices | list | import), GET/POST /api/quest and
an Import from my Quest panel on the Android page. It finds a Quest on this
computer's adb (never Frame Control's own tunnelled serials), lists third-party
apps with split, OBB and Android/data details, pulls the APKs into a per-user
cache and checks them before copying anything else: Meta's Platform SDK
licence check (libovrplatformloader.so) and 32-bit-only builds are refused,
VrApi-only builds flagged. It then installs through the normal APK path, adds
OBBs with the existing helper and, if asked, Android/data files, and always
removes the cache. No licence-check bypass is bundled, called or linked.
Tests use a fake adb (tests/fakeadb.py). Docs: docs/quest-import.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F-Droid repositories can now be added with HTTP basic auth, either as
https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with
separate username/password fields (store form, server API, and the CLI's
--username/--password-stdin). The login is stripped from the saved URL and
kept in a new shared module, apk_sources/_credentials.py, which writes
source-credentials.json in the data folder with mode 0600.
A credential is only ever added per request to its exact origin (scheme,
host, port): redirects elsewhere get nothing and have Authorization
removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the
username and password" error and saves nothing; signature, pin, TOFU,
hash and rollback checks are unchanged. Removing a repository deletes its
login.
GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as
Bearer only to api.github.com. If GitHub answers 401, the request is
retried once without it and the rejection is reported in source settings.
The source list shows only whether a token is set, never its value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call (one per 5 s poll) sent ~35 KB of frame_comfort.py, frame_status.py
and frame_steam.py over ssh, though they are already cached on the headset in a
content-addressed folder. Now the first ssh only checks that folder and runs
the cached copy; if any file is missing it answers {"comfort": "missing"} and
a second call writes the sources as before (umask 077, temp file then rename,
same version hash).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page polled POST /api/comfort status every 5 s whatever the connection,
and every POST Failure >= 500 went to diagnostics. ssh already marks link
failures, but those are still sent once per session per category, so each
session with a sleeping, away or re-keyed headset sent up to three reports
from this poll (most of 0.4.2's error reports). GET /api/status never reports
Failures at all.
- server: a polled POST read (POLLS: comfort status) that couldn't reach the
headset isn't a diagnostic, as with GET /api/status. Real comfort errors,
and link failures from starting or cancelling a session, still are.
- page: the comfort poll starts after the first status check and skips its
ssh while the Frame is offline (online === false); status checks bring
it back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frame_connected now carries headset_id, a one-way hash of the headset's
/etc/machine-id worked out on the headset (frame_status.py), and
headsets_saved, how many headsets this copy of the app has set up. It is
sent once per build for each headset. The hash goes to usage statistics
only: the status endpoint and MCP clients don't get it. docs/privacy.md
says what is sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Key check through a ProxyJump host: build the hop as our own ProxyCommand
(no agent/X11/port forwarding, no LocalCommand or RemoteCommand, no tty,
no further proxy), with -F none for the device; ProxyJump's own ssh would
inherit ForwardAgent and LocalCommand from ~/.ssh/config. Refuse
ProxyCommand and multi-hop routes, which can't be hardened.
- Identity history is judged on the current registry (named_route used a
frozen copy and fell back to accept-new).
- A key ssh saves on first contact is recorded, made private and the route
tightened as soon as it's saved, even if the login is then refused, and
for keys commands save (the loop settles them every tick).
- identity_established is a device-level flag that never clears (migrated
from last_ok), so editing or removing the address that worked can't make
a headset new again.
- The handshake waits for its reader's end marker before taking ssh's exit
as the end, so a late "Permission denied" isn't lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.4.2 sent $exception reports from the status poll (POST /api/comfort status,
frame_auth) on Linux and Windows for three states the person could not fix from
the app:
- A changed host key (REMOTE HOST IDENTIFICATION HAS CHANGED): the advice was
"Run Set Up Connection again", which checks ~/.ssh/known_hosts and never
replaces the headset's pinned key; Forget identity trusted whatever answered
next without showing it.
- No pinned key ("No ED25519 host key is known for frame-control-<id> and you
have requested strict checking"): every command said
StrictHostKeyChecking=yes, so any command that ran before (or without) the
connector's first handshake saving the key failed.
- Permission denied (publickey,password): not something a retry fixes.
Commands now use accept-new until a key is saved and yes after (accept-new
never replaces a saved key). A changed key fails at the identity stage with its
fingerprint; the banner and Devices tab offer "Check the new key", which reads
the key with a throwaway known_hosts file and no login, shows it beside the
saved one, and pins exactly that key only when the person confirms. Refused
keys point to Set Up Connection. ssh's own host key and login refusals are
marked as link failures and frame_auth joins the expected categories, so each
is reported at most once a session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue #73 (SteamOS 0.4.5): Capture on the Desktop view failed with "no app
panel is open in the headset" while the Plasma desktop was plainly open.
frame_touch.py's panels() came back empty and the page only knew to say
"none open".
- frame_touch.py: read gamescope's properties from :0 first, probing the
other displays if :0 has none; list displays from /proc/net/unix's
abstract X sockets as well as /tmp/.X11-unix; when nothing is found,
return a "why" (X tools missing, listed windows that aren't X11 windows,
desktop running but not listed, or nothing open).
- index.html: the Desktop view's Capture and Control show that reason, or
the listing error, instead of a bare "no app panel".
- server.py: /api/clipboard answers 409 when the headset's desktop isn't
open (plasmashell not running), so it no longer reaches telemetry as an
$exception; other paste failures stay 502.
Fixes#73
Co-Authored-By: Claude Opus 5.5 <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>
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>
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 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>
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>
- ~/.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>
- A report with follow-up ticked carries this copy's contact id, and the
inbox marks its permission withdrawn when a later choice from that copy
no longer agrees to follow-up questions at that address.
- The one-time prompt never appears in a visit that showed the privacy
notice, even if the Frame connects just after it's dismissed.
- Saving contact details isn't headset work: it can't hold up switching
headsets or be refused after a switch.
- Consent flags must be JSON true/false; "false" is no longer consent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>