Skip to content

Fix mobile preview rendering, discovery, and terminal controls - #467

Merged
theogravity merged 22 commits into
mainfrom
fix/mobile-preview-reliability
Oct 29, 2026
Merged

theogravity merged 22 commits into
mainfrom
fix/mobile-preview-reliability

Conversation

@theogravity

Copy link
Copy Markdown
Contributor

Fix mobile previews that lose styles or open internal service ports as downloads, and make preview labels and terminal controls usable on phones.

  • Preserve large asset responses across TCP framing and HTTP reader handoffs; keep Next.js chunk loading and repeated CSS hot reload working through the proxy.
  • Discover HTML websites with bounded IPv4/IPv6 probes, exclude internal services and downloads, and show truncated page titles in the picker and tabs.
  • Keep non-HTML navigation responses inside a closable preview error page, including download responses with error statuses.
  • Route history buttons to full-screen terminal apps through the existing input permission checks.
  • Match device and preview count badges, place ownership notices beside the action menu, and open their explanations on tap.

Validation: API (4,908), runtime (575), and web (3,151) tests passed, along with focused regressions, affected type checks, lint, and the web build. Chromium and WebKit checks covered rendering, repeated Next.js and Vite CSS hot reload, filtering, titles, download protection, terminal navigation input, and mobile header interactions. Native CUA testing was unavailable because the host has no graphical display or accessibility bus.

Includes server and node patch changesets. Not deployed.

bun run verify:changed passed its build, type, lint, unit-test, and installer checks, then stopped at a CLI update scenario that deliberately refuses hosts with an existing per-user Subshell Server service definition. That scenario needs CI or a clean host; the installed service was left untouched.

theogravity and others added 9 commits October 28, 2026 12:31
Discovery scales its probe budget with the listener count, so a pane full of
idle sockets can no longer hide the dev server, and e2e fixtures answer
text/html so they are discovered at all. One predicate in the protocol package
now decides what a web page is: discovery, the navigation guard and the
rewriter accept application/xhtml+xml instead of only text/html.

Node protocol goes 25 to 26 for the titled port rows, with the version floor
and the agent's own version raised in the same commit. The devices trigger
announces the count its badge paints, and the Turbopack shim leaves an href
alone when a page blanks its chunk base.

Co-Authored-By: Claude <noreply@anthropic.com>
The rewriter goes back to text/html only: the compat shim it injects is HTML
markup, and raw < and && inside it make a document parsed as strict XML fail
outright, so an XHTML page now previews byte-for-byte as its author wrote it
while still being discovered and navigated.

A refused navigation keeps the cookies that came with it, a page title takes the
normalizeLabel path at the wire boundary, and the alternate-screen bar sends
Home/End in the spelling the app asked for. The plane-side lane cap and the
stricter discovery status gate are documented as deliberate, not as twins.

Co-Authored-By: Claude <noreply@anthropic.com>
The two probes per port now run together instead of queueing, so a pane holding
many listeners that accept and never answer still lists its dev server, and each
loopback family gets its own window so a stalled IPv4 dial cannot hide an
IPv6-only site. A navigation whose response names no type is painted rather than
refused, since a browser renders that as text and a dev server that forgets the
header still serves a page someone asked for.

PreviewLabel's cache-wide subscription stays: query-core 5.101.4 gives no public
per-query listener, so aiming it means non-public access or an observer that
subscribes to the cache itself.

Co-Authored-By: Claude <noreply@anthropic.com>
The refusal exists because iOS hands a frame's file to a system viewer with no
way back; a desktop browser downloads in its own UI, so those bytes go through
now, and the phone's refusal page also announces itself as a dashboard toast,
believed only from the frame that posted it.

An XHTML page is rewritten after all, its shim inside a CDATA section so the XML
parser stays happy. Discovery asks both loopback families before dropping a port,
a HEAD refusal carries no body, the Turbopack probe reads the raw src before
mapping, the CSS bump matches rel as written and set later, a revoked disclosure
stops being explained mid-dialog, tab labels follow their own query instead of the
whole cache, and the compat verifier judges each leg on its own.

Co-Authored-By: Claude <noreply@anthropic.com>
…stable

Both inline scripts a rewritten document gets now go through one helper that
wraps them in CDATA for a page the browser parses as strict XML, escapes nothing
the XML parser would choke on, and skips the fallback injection that would have
landed markup before the prolog. A port whose server accepted and is still
compiling its first route is now offered untitled instead of hidden, discovery
asks both loopback families before dropping a port, and an over-long page title
is capped rather than voiding the pane's whole discovery answer.

The stylesheet cache-bust also fires when rel arrives after href, matches rel as
written, and the socket mapper returns a ws: URL, so HMR that reduced its own
address to a pathname still connects. The navigation guard moved to its own
module with unit tests, which brings http.ts back under the file-size rule.

Co-Authored-By: Claude <noreply@anthropic.com>
The socket rewrite was applied to EventSource too, which needs an http(s) URL by
spec, so every server-sent stream inside a preview would have died; it now
applies to WebSocket alone. And adding rel to the mapped attributes let the URL
mapper chew up rel="noopener" and friends, turning a security token into a
proxied path; rel now returns exactly what the app wrote, with the stylesheet
test reading it as the token list it is.

A page title is now trimmed in UTF-16 units, the way the route's own schema
counts them, without ever splitting a surrogate pair, so an emoji-heavy title
can't 500 the read that promises never to error. An untitled page on one loopback
family no longer hides a titled site on the other. The shim's two rules are
asserted in a real Chromium through spec 48 (each bug was confirmed red against
the new checks in isolation) and pinned as source invariants in the unit suite.

Co-Authored-By: Claude <noreply@anthropic.com>
The guard refused anything that was not a document, including a dev server's
plain-text log page or a PNG someone tapped, neither of which leaves the frame on
iOS. It now refuses what a browser cannot present itself: an attachment, whatever
its type claims, or an unreadable binary.

The probe budget was drained from the same leading port every poll, so a pane
whose stalled sockets sat ahead of its dev server hid that server permanently
rather than transiently; each scan now starts where the last one gave up, and
twice as many ports are asked at once. A verdict the first loopback family
already gave survives the scan's budget expiring during the second. Discovery
identifies itself as a probe instead of sending no client at all, so an internal
tool that ignores non-browser clients still reaches the picker.

Also: one map() per script src instead of two, the shim escapes its own closing
tag so a caller cannot forget to, and the title parser's JSDoc is back above the
function I had slid it under.

Co-Authored-By: Claude <noreply@anthropic.com>
// wrapper. Escaping twice is inert: a rewritten sequence no longer matches.
const code = previewCompatScript("http://127.0.0.1:3000", "</script><script>alert(1)</script>");
expect(code).not.toMatch(/<\/script/i);
expect(previewInlineScript(code, false)).toMatch(/^<script>.*<\/script>$/s);
theogravity and others added 13 commits October 28, 2026 15:43
JSON, XML and their feed dialects render as text in the frame, so the guard now
paints them and refuses only what actually leaves the screen: a file the server
hands over, or a binary the view has no reader for. Accepted with that, the
committed expectation that a preview's own JSON endpoint gets an error page is
updated, per the operator's call.

A chunk marker in a script's query (a page served through a proxy route) no
longer publishes a base no chunk matches; the pathname alone may name one. The
stylesheet twin is now found by the same token-list read as the element being
bumped, so rel="preload stylesheet" gets its revision. Accept entries with q=0
are a refusal, not an offer. The EventSource leg of the browser check held its
stream open and separated a thrown constructor from a transport error, so it can
no longer settle on whichever of open and error raced first; it was confirmed
red against the leak it exists to catch. The changeset had grown into one
sentence per round and is rewritten.

Co-Authored-By: Claude <noreply@anthropic.com>
A port is offered when something on it ANSWERED with a document, and not offered
when it merely accepted a connection. Round 7 asked for the cold dev server and I
implemented it by holding a silence as a verdict, which also advertised every
wedged database, language server and leftover bind as "Web preview", against the
feature's own promise. The window per loopback family is now generous enough for
a first compile (a late answer is listed), and the scan's deadline starts before
the /proc walk with room for the port in flight, so the pass stays inside the
plane's command timeout instead of answering nothing.

The guard's text family is a list, not a wildcard: text/csv, text/calendar and
text/vcard are how a dev server names an export, and iOS takes those to Quick
Look just as it takes a PDF. A stylesheet's revision now waits for the link to
be connected instead of firing twice, the refusal channel tolerates a hex case
difference without going dead, and the refusal page stops advising a new tab
that would only be refused again.

XHTML now has the proof it lacked: a real Chromium loads the fixture's
application/xhtml+xml document, asserts no parse error, and asserts the shim
executed; removing the CDATA wrapper was confirmed to turn that red.

Co-Authored-By: Claude <noreply@anthropic.com>
The stylesheet revision is now idempotent, which is what actually stops the
double bump: last round I guarded one of the two paths that reach it, and the
observer's append pass still rewrote href a second time, costing a second token
and a second fetch of the same sheet.

The probe window is now measured from when the probes start, with a floor, so a
/proc pass that runs long on a loaded host can no longer spend the entire budget
before the first port is tried and answer with an empty picker. Concurrent
readers of discovery join one scan instead of each firing a sweep at the node,
and a revocation drops the in-flight entry with the cache.

The compat verifier asserted the accumulated failures of the very leg whose
transients its own comment says to tolerate; the check now runs before that leg
and the arrays are cleared at its boundary. The crowded-scan test drops its
wall-clock bound, which could only fail on a busy runner rather than on a real
regression, and the two cookie extractions in the proxy are one function.

Co-Authored-By: Claude <noreply@anthropic.com>
…rest

Discovery and forwarding had two opinions of loopback: a port whose IPv4 socket accepts and stalls was listed off its IPv6 page, and the lane (which only retries ::1 after a refusal) hung on the stall. The probe now holds to what the lane can dial. CDP discovery and the screencast bridge ask both families, so a browser bound to ::1 is watched and not merely listed. The scan's probe floor can no longer outrun the plane's command timeout, a ports sweep dropped by a pane or node close cannot write itself back into the cache, the MCP views mirror takes the wire row type so a title can't go missing again, and two doc/dead-code leftovers from earlier rounds are fixed.
…pane

In the alternate buffer a press is only ever a key, so a view grantee or a copy-mode pane got silence from all four buttons. They now stand down with the reason at the control, on the same disabled-reason tooltip the rest of the bar uses, and stay live whenever the app can be driven. The terminal publishes the buffer switch through onAltScreenChange and callers clear it on dispose.
@tanstack/router-core declares ^1.6.2, and the lock had 1.6.3, which every one of the five advisories (three high) names as vulnerable. bun audit still matches the original version, so the gate went red on the advisory DB rather than on any change here; CI runs the same gate. Origin/main carries the same pin. Verify:changed's audit tier blocked on this, so the in-range update is what the script itself prescribes rather than a new ignore entry.
Round 12's two MINORs, both in last round's family policy. An IPv4 that accepts and answers something else (an API's JSON on the port a dev server serves on ::1) still handed the row its title and label, though the viewer would see the API: now an IPv4 answer settles the port alone, and IPv6 carries the offer only after a refused connect, which is exactly when forward.ts goes there too. The screencast bridge asked its two families with no bound on the first, so a wedged IPv4 listener spent the whole 3 s handshake and the follow-up fetch died on the abort; each family now gets its own slice of the budget. Also folds a doubled comment about the scan clock, and says in the changeset that the seroval raise rides in this PR.
Round 13's two MINORs. probeFamily called every failed fetch 'unreachable', so a tool that accepts on 127.0.0.1 and resets (or answers in gRPC rather than HTTP) let the IPv6 dev server carry the row, though the lane completes its TCP connect there and never walks to ::1. Only a connection that never landed now counts as absent, judged from the error codes Bun's fetch actually sets (ECONNREFUSED and friends, measured against real sockets rather than guessed). And the scan's deadline never reached the devtools probe, so a worker picking up a port at the window's end could run 800 ms past it and push the command into the plane's timeout; both probes per port are now bounded by one window. The changeset no longer claims a dev server is unhidden by a family that merely answers.
Round 14's MINOR: the ladder helper was tested with fetches that refused or answered at once, so nothing pinned the one setting that lets an IPv6-only devtools endpoint survive a stalled IPv4 listener. Two tests now do, one at the helper and one at the discovery call site, both verified red by removing the slice (the probe then sits out the caller's whole deadline). The NIT: the changeset said a dev server is no longer hidden by 'a first family that never answers' while the next sentence uses that same phrase for the case that still hides it; the first now says 'an IPv4 that is not listening at all'.
Round 15's NIT, which cost one stub to close: both slice tests let the family window REPLACE the caller's signal without anything going red, because in each case some timer still ends the wait. This one aborts the caller at 30 ms with a 3 s family slice, so only the composition can answer in time; dropping it fails at 3001 ms. Verified by mutation, source restored.
PR #468 landed the same raise with its own note in tidy-seroval-security.md, so this branch saying it also delivers the fix would be a duplicate claim in the release body. The merge of origin/main takes the lockfile entry too, leaving only this PR's own two lock changes: the node agent's 3.1.0 (which versions.ts requires for protocol 26) and the entities dependency discovery's title decode now needs.
@theogravity
theogravity merged commit c43bbbe into main Oct 29, 2026
14 checks passed
@theogravity
theogravity deleted the fix/mobile-preview-reliability branch October 29, 2026 01:05
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants