Removing a waiting import hid the progress of the one still running.
Found by GPT-6.1 Sol review round 1.
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>
From a live test with a real account (71 owned entries):
- the size was cut off behind the package name; sizes now come first;
- a required data file Frame Control can't place as an OBB (The Climb's
1.8 GB main OBB) was never mentioned; the row now says it won't be
installed;
- the count said games, but Meta's list includes apps and environments.
Problems and warnings get their own coloured line; the verdict colours
now win over the row's grey text (they were grey before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From review: the time zone setting could hold any text (a TZ like
home/alex/private passed the name check), so the app now looks the zone up
in a table made from the tz database (scripts/timezones.py) and sends only
its two-letter country; anything not in the table sends nothing.
Turning a setting on no longer counts as having seen the new notice, and
the country is taken out at send time until it has been, so it can't
leave early whatever order things happen in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opens, installs and updates carry the computer's time zone name (from
Electron, which knows it on every OS), so we can see roughly which
countries Frame Control is used in without ever seeing an address.
The first-run notice now has a version. Copies that saw the older notice
see it again, and keep sending their usual statistics, but leave the time
zone out until they've seen the new one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- cancel_signin marks an in-flight attempt and, independently, deletes the
account if that attempt saved it, so a cancel landing after the save but
before the connect's cleanup still removes the token.
- Sign-out runs one at a time (a second call joins it) and refuses new
sign-ins while it runs; it goes round until no window, connect or cancel is
left. The page keeps Sign in with Meta off from pressing Disconnect until
the server has deleted the token.
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>
The desktop app can now open Meta's own login page (https://secure.oculus.com,
as BSManager's "Log in with Meta") in a window with its own session
(persist:meta-signin: sandboxed, context isolation, no preload or Node,
permissions refused). Navigation stays on https oculus.com, meta.com and
facebook.com; other links open in the system browser and other redirects are
stopped. The oc_ac_at cookie is read only from that session and only for
oculus.com, sent once per value through the server's existing connect path
(check_token, who am I, 0600 file), and the window closes. The page only gets
the server's answer, never the token. Disconnect also signs out of Meta in
Frame Control by clearing that session. Pasting a token stays as the fallback.
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>
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>
- Split merging only accepts splits holding lib/<abi>/*.so, assets/, their own
signature/manifest and an empty resource table. Code (classes*.dex), other
files, resource splits, feature splits and split manifests declaring
components or libraries are refused before anything reaches the Frame.
- Merged bytes are bounded (512 MB per file, 1 GB in all) from the headers and
again by bounded reads with a checksum, so a zip bomb or a forged header
can't fill memory.
- Import never starts the game. The first import installs it; game data (OBB,
Android/data) is copied by a second import while the person has the game
open, never replacing an open game's APK, then the game is closed.
- Android/data is streamed as a tar straight into the SSH transfer, not
staged; the space check counts split payloads uncompressed and data once.
- The panel shows small sizes in KB rather than "1 MB".
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>
Follow-up to #85 from #87:
- A failure can carry a short reason (SSH identity changed, Saved SSH identity
missing, This computer's key refused). The address that failed shows it
instead of repeating the whole message, so the offline banner no longer
reads "...may be answering here.." or "...must be on).."; the banner also
drops a closing full stop before adding its own and keeps "SSH"/"Frame"
capitalised.
- The header pill leads with that reason (then the countdown), so a narrow
pill cuts the countdown, not the reason. The Devices tab's changed-key and
refused-key explanations wrap; the list row shows the short reason with the
whole message as its tooltip.
- The header's pill and buttons sit in one group pushed right with tighter
gaps; the pill and headset picker give way, the buttons don't, and the
shortcut hints hide at 1366 px and below. Refresh no longer overhangs at
1280 px.
- Use this headset selects the headset it switched to.
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>
- Key check: ssh writes the key during the key exchange and is stopped at once;
no authentication, agent, forwarding, commands (-N), terminal or local
command, all forced ahead of ~/.ssh/config, and -F none unless a jump host
needs the config. A device accepting "none" gets nothing.
- accept-new only for a headset that never had an identity: the saved key's
fingerprint is recorded in devices.json, and a recorded identity, an
existing pin file or a connection that ever worked makes a deleted, empty,
unreadable or unparseable pin fail closed ("missing or can't be read").
- forget-identity is refused (it let the next contact save any key unseen).
- A key read before the headset's port, user or that address changed can't be
trusted.
- Without a ControlMaster (Windows) the handshake's stderr goes to a file read
as it grows, not a pipe.
- Pin files and the check's throwaway known_hosts get make_private.
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>
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>
Review of #83: the display search shares one 6 s deadline (2 s per display)
and is remembered (found for good, not found for 30 s), so a stalled display
can't use up the server's 20 s. Control now shows a failed panel listing
instead of "Finding the headset's panels..." when the first one fails.
Page test (tests/page/desk_panels.mjs) covers the Desktop listing: the
headset's reason, initial failure and recovery.
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>
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>
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>
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>
- from_report applies its change and reads the id and rev together, so a
removal made while that change is sending is newer than the report; the
report's redaction window now starts before the address is saved.
- A report with a different address replaces the saved one with follow-up
questions only: update notices aren't carried over to an address nobody
agreed them for, and the form says so before sending.
- Settings refreshes after every report send, whatever the box shows by then.
- privacy.md: a report with follow-up ticked also saves and sends the address.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Ticking follow-up questions on a report makes that address the contact
email (follow-up ticked, update choice unchanged), so Settings shows it
and Remove my email withdraws it like any other.
- Reports carry contact_rev; the inbox takes a report's follow-up
permission back when a later change from that copy (higher rev) no
longer agrees, whatever the clocks say.
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>
- A right-click menu open when the headset changes closes, so it can't act on the other headset's shot.
- A preview being retried by Refresh is no longer dropped when a background check lands first.
- A late failure from a headset switched away from no longer drops the new headset's preview.
- Tab and Escape close the menu and give focus back to where it was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- In Control, a right-click on the viewer goes to the Frame only; the copy menu stays out of the way.
- Thumbnails no longer hold up the next check: a save shows as saved straight away, and a
new shot appears while older previews are still loading.
- Refresh (or a save) during a background check reads again after it, so the answer is fresh.
- A preview that failed is retried on Refresh, not by every background check.
- Copy reports a failure if the app refuses the image, and if the browser can't copy text.
- Windows: Show in File Explorer works when the path has spaces.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Second review follow-ups:
- Saving returns once the choice is stored; a send already under way picks up
the newest change, or the background retry is woken.
- A problem report still being sent when its address is removed is logged as
<removed>, checked under the same lock the removal holds.
- The prompt re-checks the privacy notice after fetching its state.
- docs/privacy.md: offline contact changes are sent later by themselves; the
prompt never follows straight after the privacy notice.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-ups:
- Each contact_consent event carries a rev that goes up with every change,
sends are serialized, and `contacts` picks every field from the highest
rev per copy, so a withdrawal can't lose to an earlier event sent in the
same second or with a skewed clock.
- Removing the address also replaces it with <removed> in the local
sent log (earlier contact events and problem reports).
- The prompt is rechecked when the Frame connects, not only at page load.
- No thanks hides the bar only once the dismissal is saved.
- docs/privacy.md: say that the analytics switches don't block a report or
contact change the person sends deliberately.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Problem reports arrive with no way to reply. People can now leave an email
address with two separate opt-ins: occasional update notices, and follow-up
questions from the maintainer.
- ui/frame_contact.py keeps the address and choices locally and sends each
change privately to PostHog as a contact_consent event under its own random
contact id; removing the address sends a withdrawal without it. Changes made
offline wait and are retried.
- A one-time, dismissible prompt appears after the Frame first connects; No
thanks and showing it once are both remembered.
- Privacy & updates gains a Contact email section to add, change or remove it.
- The report form's contact field now goes with a report only when "may
contact me with follow-up questions" is ticked (contact_followup).
- frame_report.py contacts [updates|followup] lists who agreed to what,
using the newest event per copy.
- docs/privacy.md says what is collected, why, where and how to remove it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each screenshot card and the viewer get a Copy button that puts the image on
the clipboard (natively in the desktop app, as PNG in a browser). Right-click
a screenshot to open, copy, save, show it in Finder, or copy its path or name;
right-click the viewer to copy or save. The shelf re-lists the Frame's
screenshots every 8 s while the window is visible and connected, redraws only
when something changed, and keeps thumbnails it already has. Switching
headsets clears the list and ignores answers still on their way.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Preserve the complete store, artwork, telemetry, input, media and agent route table alongside the newly landed VR utilities and performance HUD.
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Keep the union of server routes, desktop resources and responsive controls. Preserve OpenXR install defaults and telemetry hooks alongside library artwork. Adapt the resource test to single-file entries and avoid a completed-refresh race in the F-Droid test.
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
Also from review: a SteamVR build without the timing exports can't break status
(AttributeError), and the device test class runs when the file is run directly.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>