On iOS the file viewer showed only the first page of a PDF, with no way
to scroll to the rest — the document had to be downloaded and opened in
another app. The cause was not ours: WebKit refuses to mount its PDF
viewer inside an <iframe>/<object>/<embed> and paints a static first-page
thumbnail instead. That hits Safari on iOS and every WKWebView, so the
native shell too. The full viewer only exists for a top-level navigation.
The desktop browsers do mount a viewer, but each mounts its own — Chrome's
toolbar, Safari's page-index sidebar — so the same document also looked
different on every machine.
Both are answered by drawing the pages ourselves. New <pdf-view>
(web/components/shared/pdf-view.js) renders a continuous scroll of canvas
pages on the vendored pdf.js, with a zoom control and a page counter, and
replaces the iframe for both native .pdf files and server-compiled LaTeX.
Three properties are load-bearing:
- pdf.js is imported lazily (~450 KB + a 1.2 MB worker), so a session that
never opens a PDF never pays for it.
- Canvases are created and destroyed as pages scroll. iOS caps the total
canvas backing store a page may hold and silently blanks canvases past
it, so an eager render would come out empty on exactly the platform this
was written for. Off-screen pages keep only a correctly-sized box, which
is also what keeps the scrollbar honest.
- The text layer (selection, in-page find) is best-effort: it is
transparent DOM over the pixels, so its failures are swallowed rather
than surfaced.
Vendored from pdfjs-dist 6.2.108: pdf.min.mjs, pdf.worker.min.mjs, the
standard-font data (needed by PDFs that reference Helvetica/Times without
embedding them) and the .textLayer block of pdf_viewer.css. CJK cmaps are
deliberately left out. pdf.js 6 needs Safari/iOS 17.4+.
Verified in headless Chromium against both a synthetic 12-page PDF using
non-embedded Helvetica and a real 14-page paper with embedded fonts and
figures: all pages present, last page renders after scrolling, page 1
released off-screen, text layer populated, zoom re-renders at the new
scale, no JS errors.
The agent's control WS to the relay was purely reactive: it answered the
relay's Ping with a Pong and otherwise never wrote anything for long
stretches. So when the path broke silently — NAT rebinding, a reverse proxy
dropping its state — there were no unacked bytes for the kernel to
retransmit, the socket never errored, and the relay's Close (it gives up
after 120s of quiet) fell into the same hole. stream.next() then parked
forever on a socket to nobody, is_connected() kept answering true, and the
reconnect schedule below it — which works fine, it just never got asked —
was never reached. Only a process restart cleared it.
Relay logs show the cost: three idle-timeout closes of the agent connection
with the agent absent for 2h, 9h and >2h afterwards, while every disconnect
it *did* notice was back in 2-4 seconds. During one of those windows the iOS
client authenticated four times and not a single pipe matched: pipe_invite
rides the E2E channel through the agent's WS, so with the agent gone the web
view had nothing to tunnel through.
Add a per-session liveness probe. Both halves matter: a WS Ping every 20s
keeps unacked bytes on the wire so a dead path finally surfaces as a TCP
error (and the relay's Pong refreshes its own idle timer), and 75s of
inbound silence — two missed relay pings — returns Err, handing the session
to the existing backoff schedule.
Covered by a test against a relay that completes the v2 handshake and then
goes mute, the shape a black-holed path leaves behind. It reads the raw TCP
stream rather than ws.next() because tungstenite auto-answers a Ping with a
Pong on the next read, which would keep last_seen fresh and defeat the
silence being simulated. Without the probe the test hangs instead of
redialing.