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
| Swift | why it is copied | |
|---|---|---|
closePDF with nothing open | optional-chained dismiss() is a no-op on nil, still resolves | a page that closes twice gets two successes |
data: handling | tests hasPrefix("data:") but strips with replacingOccurrences of the full PDF header — every occurrence, anywhere, not the prefix | a data:image/png source takes the base64 branch, has nothing stripped, and is refused; a stricter strip would accept sources the Swift arm rejects |
page | passed to page(at:), which is 0-based, behind a page > 0 guard | page: 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 getDeviceInfoWithout it that assertion would have gone green on dbExecute's dispatch, exactly as the i=30 one did before.