ReviewOS

also looking at this

craft-native/craft

iOS: the PDF viewer — ratchet 40 → 38

#95
Merged glennmichael123 wants to merge feat/ios-pdf into main
8 files +697 -3
10 days ago

openPDF and closePDF, migrated together, because they cannot be split.

Why the asymmetry is a trap

openPDF is gated on config.enablePDFViewer; closePDF is ungated. That reads like an invitation to take the ungated one first. But the two share one field: Swift stores the presented controller in pdfViewController, and closePDF is nothing but pdfViewController?.dismiss().

Moving one would put the controller in Zig and the dismiss in Swift, where the field is nil — so closePDF would resolve true having dismissed nothing, and the user would be left holding a full-screen document.

Same shape as the blocker recorded in bridge_mobile_locrecording.zig, opposite outcome: there the state is re-adopted by Swift on every launch and Zig cannot take it; here it is in-memory for one presentation and both halves move at once.

Presenting

Through bridge_mobile_system.uikit.topmostViewController(), not Swift's windows.first?.rootViewController. The walk up presentedViewController is the difference: presenting on a controller that is already presenting logs "which is already presenting" and does nothing — which in Swift's version means the {"opened":true} reply is sent for a PDF that never appeared.

The close button stays

modalPresentationStyle = .fullScreen disables the swipe-to-dismiss a sheet would have, so the button is the only way out of the viewer that does not require the page to call closePDF. Dropping it is the tempting simplification — it costs a runtime class, four constraints and an SF Symbol — and it would trap the user in the document.

Its target is retained for the life of the process: addTarget:action:forControlEvents: does not retain, so a released target is a message to freed memory on the first tap rather than a leak.

Three behaviours that look like bugs and are the contract

Swiftwhy it is copied
closePDF with nothing openoptional-chained dismiss() is a no-op on nil, still resolvesa page that closes twice gets two successes
data: handlingtests hasPrefix("data:") but strips with replacingOccurrences of the full PDF header — every occurrence, anywhere, not the prefixa data:image/png source takes the base64 branch, has nothing stripped, and is refused; a stricter strip would accept sources the Swift arm rejects
pagepassed to page(at:), which is 0-based, behind a page > 0 guardpage: 1 shows the second sheet and there is no way to ask for the first; a page passing 1 today lands on sheet two, and "correcting" it would silently move them

Verification

zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors.

28 fixture assertions pass on a simulator, two new:

ok: PDF parsed and presented full screen (i=46 seen 1x)
ok: the same module that presented the viewer dismissed it (i=44 seen 1x)

The page sends a real 310-byte one-page PDF as the data: URL a page would actually send — the minimal document was verified against PDFKit before being embedded — and pageCount comes back from PDFKit parsing those bytes, so it cannot be produced without loading the document. closePDF then dismisses the controller openPDF stored, which is what proves both halves live in the same language.

The marker-id collision guard added last round earned itself back on the first run of this one:

==> marker ids
FAIL: asserted id 42 is posted by 2 actions: dbExecute getDeviceInfo

Without it that assertion would have gone green on dbExecute's dispatch, exactly as the i=30 one did before.

8 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.