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
packages/ios/fixtures/zig-slice/build-and-run.shmodified+16-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -63,7 +63,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6363 "$OUT/main.o" "$OUT/page.o" \
6464 "$ROOT/packages/zig/zig-out/lib/$LIB" \
6565 -framework UIKit -framework WebKit -framework Foundation -framework Security \
66 -framework Vision -framework LocalAuthentication \
66 -framework Vision -framework LocalAuthentication -framework PDFKit \
6767 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6868 -o "$APP/CraftSlice"
6969
@@ -101,6 +101,7 @@ for _ in $(seq 1 60); do
101101 if grep -q 'i=26' "$LOG" 2>/dev/null && grep -q 'i=28' "$LOG" 2>/dev/null \
102102 && grep -q 'i=32' "$LOG" 2>/dev/null && grep -q 'i=22' "$LOG" 2>/dev/null \
103103 && grep -q 'i=34' "$LOG" 2>/dev/null && grep -q 'i=38' "$LOG" 2>/dev/null \
104 && grep -q 'i=46' "$LOG" 2>/dev/null && grep -q 'i=44' "$LOG" 2>/dev/null \
104105 && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null; then sleep 1; break; fi
105106 sleep 1
106107done
@@ -283,4 +284,18 @@ C38="$(count 38)"
283284[ "$C38" -ge 1 ] || { echo "FAIL: the deletion completion never fired, or its reply was lost"; exit 1; }
284285echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)"
285286
287C46="$(count 46)"
288# pageCount is PDFKit's answer for the bytes the page sent, so it cannot be
289# produced without actually loading the document — and openPDF only replies
290# after presenting, so a viewer is on screen at this point.
291[ "$C46" -ge 1 ] || { echo "FAIL: openPDF did not load and present the document"; exit 1; }
292echo "ok: PDF parsed and presented full screen (i=46 seen ${C46}x)"
293
294C44="$(count 44)"
295# The half that cannot be split from the other: closePDF dismisses the
296# controller openPDF stored, so this passing is what proves both halves live
297# in the same language.
298[ "$C44" -ge 1 ] || { echo "FAIL: closePDF did not dismiss the viewer"; exit 1; }
299echo "ok: the same module that presented the viewer dismissed it (i=44 seen ${C44}x)"
300
286301echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -35,7 +35,7 @@
3535 "enableBackgroundLocation": false,
3636 "enableBackgroundTasks": false,
3737 "enableScreenCapture": true,
38 "enablePDFViewer": false,
38 "enablePDFViewer": true,
3939 "enableAR": false,
4040 "enableMLKit": true,
4141 "trustedOrigins": []
packages/ios/fixtures/zig-slice/page.mmodified+22-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -49,6 +49,7 @@ const char craft_slice_page[] =
4949 "var sentShot = false, sentDeepLink = false;\n"
5050 "var sentOcr = false, sentClassify = false, sentDetect = false;\n"
5151 "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n"
52 "var sentPdfOpen = false, sentPdfClose = false;\n"
5253 "window.__craftBridgeError = function (ctx) {\n"
5354 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5455 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -161,6 +162,16 @@ const char craft_slice_page[] =
161162 " // come back over the background-queue hop with its ticket intact.\n"
162163 " // The reply echoes both fields the page sent, so it proves the payload\n"
163164 " // was parsed and routed — and the donation ran without throwing.\n"
165 " if (!sentPdfOpen && action === 'openPDF' && payload\n"
166 " && payload.opened === true && payload.pageCount === 1) {\n"
167 " sentPdfOpen = true;\n"
168 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:46});\n"
169 " bridge.postMessage({t:'mobile', a:'closePDF', i:99});\n"
170 " }\n"
171 " if (!sentPdfClose && action === 'closePDF' && payload === true) {\n"
172 " sentPdfClose = true;\n"
173 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:44});\n"
174 " }\n"
164175 " if (!sentSiriAdd && action === 'registerSiriShortcut' && payload\n"
165176 " && payload.registered === true && payload.action === 'sliceAct'\n"
166177 " && payload.phrase === 'open the slice') {\n"
@@ -263,6 +274,17 @@ const char craft_slice_page[] =
263274 " // NSUserActivity donation: no Intents, no permission, no UI.\n"
264275 " bridge.postMessage({t:'mobile', a:'registerSiriShortcut',\n"
265276 " d: JSON.stringify({action:'sliceAct', phrase:'open the slice'}), i:96});\n"
277 " // A real one-page PDF, carried as the data: URL a page would actually\n"
278 " // send. pageCount comes from PDFKit parsing these bytes, so it cannot\n"
279 " // be produced without loading the document.\n"
280 " var PDF = 'data:application/pdf;base64,';\n"
281 " PDF += 'JVBERi0xLjQKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoK';\n"
282 " PDF += 'MiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAw';\n"
283 " PDF += 'IG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCAyMDAgMjAwXT4+';\n"
284 " PDF += 'CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAg';\n"
285 " PDF += 'biAKMDAwMDAwMDA1NCAwMDAwMCBuIAowMDAwMDAwMTA1IDAwMDAwIG4gCnRyYWlsZXIKPDwv';\n"
286 " PDF += 'U2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMTcwCiUlRU9GCg==';\n"
287 " bridge.postMessage({t:'mobile', a:'openPDF', d: JSON.stringify({source:PDF}), i:98});\n"
266288 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
267289 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
268290 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -410,6 +410,9 @@ pub fn build(b: *std.Build) void {
410410 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_siri.zig", .{
411411 .root_source_file = b.path("src/bridge_mobile_siri.zig"),
412412 });
413 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_pdf.zig", .{
414 .root_source_file = b.path("src/bridge_mobile_pdf.zig"),
415 });
413416 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
414417 .root_source_file = b.path("src/bridge_mobile.zig"),
415418 });
packages/zig/src/bridge_mobile_pdf.zigadded+650-0
Changes to packages/zig/src/bridge_mobile_pdf.zig
@@ -0,0 +1,650 @@
1//! The `mobile` namespace's PDF viewer: `openPDF` and `closePDF`.
2//!
3//! ## They migrate together, or not at all
4//!
5//! `openPDF` is gated on `config.enablePDFViewer`; `closePDF` is ungated. That
6//! asymmetry is a trap, because the two share one piece of state: Swift stores
7//! the presented controller in `pdfViewController` and `closePDF` is nothing
8//! but `pdfViewController?.dismiss()`. Migrating only the gated one would put
9//! the controller in Zig and the dismiss in Swift, where the field is nil — so
10//! `closePDF` would resolve `true` having dismissed nothing, and the user would
11//! be left holding a full-screen PDF with no way out but the close button.
12//!
13//! This is the same shape as the recorded blocker in
14//! `bridge_mobile_locrecording.zig`, and the opposite outcome: there the state
15//! is re-adopted by Swift on every launch and Zig cannot take it, here it is
16//! in-memory for one presentation and both halves move at once.
17//!
18//! ## Presenting
19//!
20//! Through `bridge_mobile_system.uikit.topmostViewController()`, not Swift's
21//! `windows.first?.rootViewController`. The difference is the walk up
22//! `presentedViewController`: presenting on a controller that is already
23//! presenting logs "which is already presenting" and does nothing, which in
24//! Swift's version means the reply below is sent for a PDF that never
25//! appeared. Same reasoning as `bridge_mobile_contactpicker.zig`.
26//!
27//! ## The close button is not decoration
28//!
29//! `modalPresentationStyle = .fullScreen` disables the swipe-to-dismiss that a
30//! sheet would have, so the button is the only way a user can leave the viewer
31//! without the page calling `closePDF`. Dropping it — the tempting
32//! simplification, since it costs a runtime class, four constraints and an SF
33//! Symbol — would trap the user in the document.
34//!
35//! Its target is a class built by `ios_delegate.defineClass`, and the instance
36//! is retained for as long as the controller is: `addTarget:action:forControlEvents:`
37//! does **not** retain its target, so a released one is a message to freed
38//! memory on the first tap rather than a leak.
39//!
40//! ## What is carried across exactly
41//!
42//! - **`openPDF` resolves `{"opened":true,"pageCount":N}`** and `closePDF`
43//! the bare JSON `true`.
44//! - **`closePDF` succeeds when nothing is open.** Swift's
45//! `pdfViewController?.dismiss()` is a no-op on nil and still resolves, so
46//! a page that closes twice gets two successes rather than an error.
47//! - **The `data:` handling, including its sharp edge.** Swift tests
48//! `hasPrefix("data:")` but then strips with
49//! `replacingOccurrences(of: "data:application/pdf;base64,", with: "")` —
50//! every occurrence, anywhere in the string, not the prefix. So a source of
51//! `data:image/png;base64,…` takes the base64 branch, has nothing stripped,
52//! fails to decode and is refused as an unreadable PDF. That is preserved:
53//! the alternative is accepting sources the Swift arm rejects, which is a
54//! behaviour change dressed as a bug fix.
55//! - **`page` is 1-based-ish, and wrong.** Swift does
56//! `if page > 0, let targetPage = document.page(at: page)`, and
57//! `page(at:)` is 0-based — so `page: 1` shows the *second* page and there
58//! is no way to ask for the first that is not also "no navigation". Copied
59//! exactly, because a page passing 1 today lands on sheet two and a
60//! "corrected" version would silently move it.
61
62const std = ;
63const builtin = ;
64const capabilities = ;
65const bridge_error = ;
66const objc_runtime = ;
67const ios_delegate = ;
68const bridge_mobile_system = ;
69
70const objc = objc_runtime.objc;
71const BridgeError = bridge_error.BridgeError;
72const Id = ?*anyopaque;
73const is_darwin = builtin.target.os.tag.isDarwin();
74
75pub const A = struct {
76 pub const open_pdf = "openPDF";
77 pub const close_pdf = "closePDF";
78};
79
80pub const capability_actions = [_]capabilities.ActionDecl{
81 .{ .name = A.open_pdf, .reply = .result },
82 .{ .name = A.close_pdf, .reply = .result },
83};
84
85/// The exact prefix Swift strips, and only this one.
86const data_url_prefix = "data:application/pdf;base64,";
87
88/// `kPDFDisplaySinglePageContinuous`.
89const display_mode_single_page_continuous: c_long = 1;
90/// `kPDFDisplayDirectionVertical`.
91const display_direction_vertical: c_long = 0;
92/// `UIModalPresentationFullScreen`.
93const modal_presentation_full_screen: c_long = 0;
94/// `UIButtonTypeSystem`.
95const button_type_system: c_long = 1;
96/// `UIControlEventTouchUpInside`.
97const control_event_touch_up_inside: c_ulong = 1 << 6;
98/// `UIControlStateNormal`.
99const control_state_normal: c_ulong = 0;
100
101const close_button_inset: f64 = 16;
102const close_button_size: f64 = 32;
103
104/// `closePDF`'s reply: the bare JSON `true`, per `.fragmentsAllowed`.
105const close_reply = "true";
106
107/// The presented controller and the object its close button targets.
108///
109/// Both retained. `addTarget:action:forControlEvents:` holds its target
110/// weakly, so the closer has to outlive the presentation on its own; and the
111/// controller reference is what `closePDF` dismisses.
112var presented_controller: Id = null;
113var close_target: Id = null;
114
115pub const PdfBridge = struct {
116 allocator: std.mem.Allocator,
117
118 const Self = @This();
119
120 pub fn init(allocator: std.mem.Allocator) Self {
121 return .{ .allocator = allocator };
122 }
123
124 pub fn deinit(_: *Self) void {}
125
126 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
127 if (std.mem.eql(u8, action, A.open_pdf)) {
128 try self.openPdf(data);
129 } else if (std.mem.eql(u8, action, A.close_pdf)) {
130 try self.closePdf();
131 } else {
132 return BridgeError.UnknownAction;
133 }
134 }
135
136 /// Load the document, present it, and answer — all synchronously.
137 ///
138 /// Swift wraps the body in `DispatchQueue.main.async`, but
139 /// `didReceiveScriptMessage` already runs on the main thread and
140 /// `present:animated:` returns immediately, so there is no completion to
141 /// wait for and no `ios_async` ticket. The reply goes out inside the
142 /// dispatch frame that holds this call's id.
143 fn openPdf(self: *Self, data: []const u8) !void {
144 if (!is_darwin) return BridgeError.PlatformNotSupported;
145
146 const request = try readRequest(self.allocator, data);
147 defer self.allocator.free(request.source);
148
149 const document = try loadDocument(self.allocator, request.source);
150 defer release(document);
151
152 const host = bridge_mobile_system.uikit.topmostViewController() catch |err| {
153 std.log.warn("openPDF: nowhere to present from: {}", .{err});
154 return BridgeError.NativeCallFailed;
155 };
156
157 const view = try buildPdfView(document, request.page);
158 const controller = try buildViewController(view);
159
160 // Replace rather than refuse, matching Swift: a second open while one
161 // is up stacks on top of the first, and the reference kept here is the
162 // newer one. Releasing the older reference is safe because UIKit
163 // retains anything it is presenting.
164 release(presented_controller);
165 presented_controller = controller;
166
167 const sel_present = objc.sel_registerName("presentViewController:animated:completion:") orelse
168 return BridgeError.NativeCallFailed;
169 const PresentFn = *const fn (Id, objc.SEL, Id, bool, Id) callconv(.c) void;
170 const present: PresentFn = @ptrCast(&objc.objc_msgSend);
171 present(host, sel_present, controller, true, null);
172
173 const count = pageCount(document);
174 var buf: [64]u8 = undefined;
175 const json = try std.fmt.bufPrint(&buf, "", .{count});
176 bridge_error.sendResultToJS(self.allocator, A.open_pdf, json);
177 }
178
179 /// Dismiss whatever is up and say so.
180 ///
181 /// Resolves even when nothing was open, because Swift's optional-chained
182 /// `dismiss()` is a no-op on nil and still calls `resolveCallback`. A page
183 /// that closes twice gets two successes.
184 fn closePdf(self: *Self) !void {
185 if (!is_darwin) return BridgeError.PlatformNotSupported;
186 dismissPresented();
187 bridge_error.sendResultToJS(self.allocator, A.close_pdf, close_reply);
188 }
189};
190
191const Request = struct {
192 /// Owned.
193 source: []u8,
194 page: i64,
195};
196
197/// `source` is required; `page` defaults to 0.
198///
199/// Swift's arm is `if config.enablePDFViewer, let source = body["source"] as?
200/// String` with no `else`, so a missing source settles nothing and the page
201/// waits out its timeout. Not carried across.
202fn readRequest(allocator: std.mem.Allocator, data: []const u8) !Request {
203 var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
204 error.OutOfMemory => return err,
205 else => return BridgeError.InvalidJSON,
206 };
207 defer parsed.deinit();
208
209 const object = switch (parsed.value) {
210 .object => |o| o,
211 else => return BridgeError.InvalidJSON,
212 };
213
214 const source_value = object.get("source") orelse return BridgeError.MissingData;
215 const source_text = switch (source_value) {
216 .string => |s| s,
217 else => return BridgeError.InvalidParameter,
218 };
219 if (source_text.len == 0) return BridgeError.InvalidParameter;
220
221 // `body["page"] as? Int ?? 0`: absent defaults, and a non-integer is
222 // refused here rather than silently rewritten to 0.
223 const page: i64 = switch (object.get("page") orelse std.json.Value{ .integer = 0 }) {
224 .integer => |i| i,
225 .null => 0,
226 else => return BridgeError.InvalidParameter,
227 };
228
229 return .{
230 .source = allocator.dupe(u8, source_text) catch return BridgeError.AllocationFailed,
231 .page = page,
232 };
233}
234
235/// A `PDFDocument`, +1, from either a `data:` source or a URL.
236fn loadDocument(allocator: std.mem.Allocator, source: []const u8) !Id {
237 const PDFDocument = objc.objc_getClass("PDFDocument") orelse {
238 std.log.warn("openPDF: PDFDocument is not in this process; the app does not link PDFKit", .{});
239 return BridgeError.PlatformNotSupported;
240 };
241 const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed;
242 const allocated = objc.msgSendId(PDFDocument, sel_alloc) orelse return BridgeError.NativeCallFailed;
243
244 const document = if (std.mem.startsWith(u8, source, "data:"))
245 try documentFromBase64(allocator, allocated, source)
246 else
247 try documentFromUrl(allocator, allocated, source);
248
249 return document orelse {
250 std.log.warn("openPDF: the source did not load as a PDF", .{});
251 return BridgeError.InvalidParameter;
252 };
253}
254
255fn documentFromBase64(allocator: std.mem.Allocator, allocated: Id, source: []const u8) !Id {
256 const stripped = try stripDataPrefix(allocator, source);
257 defer allocator.free(stripped);
258
259 const ns_base64 = objc.createNSString(stripped, allocator) catch return BridgeError.AllocationFailed;
260 const NSData = objc.objc_getClass("NSData") orelse return BridgeError.NativeCallFailed;
261 const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed;
262 const sel_init_b64 = objc.sel_registerName("initWithBase64EncodedString:options:") orelse
263 return BridgeError.NativeCallFailed;
264 const data_alloc = objc.msgSendId(NSData, sel_alloc) orelse return BridgeError.NativeCallFailed;
265
266 const InitB64Fn = *const fn (Id, objc.SEL, Id, c_ulong) callconv(.c) Id;
267 const initB64: InitB64Fn = @ptrCast(&objc.objc_msgSend);
268 const ns_data = initB64(data_alloc, sel_init_b64, ns_base64, 0) orelse return null;
269 defer release(ns_data);
270
271 const sel_init = objc.sel_registerName("initWithData:") orelse return BridgeError.NativeCallFailed;
272 return objc.msgSendId1(allocated, sel_init, ns_data);
273}
274
275fn documentFromUrl(allocator: std.mem.Allocator, allocated: Id, source: []const u8) !Id {
276 const url = objc.createNSURL(source, allocator) catch return BridgeError.AllocationFailed;
277 if (url == null) return null;
278 const sel_init = objc.sel_registerName("initWithURL:") orelse return BridgeError.NativeCallFailed;
279 return objc.msgSendId1(allocated, sel_init, url);
280}
281
282/// Swift's `replacingOccurrences(of: data_url_prefix, with: "")`, faithfully.
283///
284/// Every occurrence, not the prefix — see the module comment. A source that
285/// begins `data:` but carries a different media type keeps its whole header
286/// and then fails to decode, which is the refusal Swift produces.
287fn stripDataPrefix(allocator: std.mem.Allocator, source: []const u8) ![]u8 {
288 var out: std.ArrayListUnmanaged(u8) = .empty;
289 errdefer out.deinit(allocator);
290
291 var rest = source;
292 while (std.mem.indexOf(u8, rest, data_url_prefix)) |at| {
293 try out.appendSlice(allocator, rest[0..at]);
294 rest = rest[at + data_url_prefix.len ..];
295 }
296 try out.appendSlice(allocator, rest);
297 return out.toOwnedSlice(allocator);
298}
299
300fn pageCount(document: Id) u64 {
301 const sel = objc.sel_registerName("pageCount") orelse return 0;
302 const CountFn = *const fn (Id, objc.SEL) callconv(.c) c_ulong;
303 const countFn: CountFn = @ptrCast(&objc.objc_msgSend);
304 return @intCast(countFn(document, sel));
305}
306
307/// A configured `PDFView`, +1.
308fn buildPdfView(document: Id, page: i64) !Id {
309 const PDFView = objc.objc_getClass("PDFView") orelse return BridgeError.PlatformNotSupported;
310 const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed;
311 const sel_init_frame = objc.sel_registerName("initWithFrame:") orelse
312 return BridgeError.NativeCallFailed;
313
314 const allocated = objc.msgSendId(PDFView, sel_alloc) orelse return BridgeError.NativeCallFailed;
315 const InitFrameFn = *const fn (Id, objc.SEL, objc.CGRect) callconv(.c) Id;
316 const initFrame: InitFrameFn = @ptrCast(&objc.objc_msgSend);
317 const zero = objc.CGRect{ .origin = .{ .x = 0, .y = 0 }, .size = .{ .width = 0, .height = 0 } };
318 const view = initFrame(allocated, sel_init_frame, zero) orelse return BridgeError.NativeCallFailed;
319
320 try setObject(view, "setDocument:", document);
321 try setBool(view, "setAutoScales:", true);
322 try setLong(view, "setDisplayMode:", display_mode_single_page_continuous);
323 try setLong(view, "setDisplayDirection:", display_direction_vertical);
324
325 // `page(at:)` is 0-based and Swift guards on `page > 0`, so 1 shows the
326 // second sheet. Copied rather than corrected — see the module comment.
327 if (page > 0) {
328 const sel_page_at = objc.sel_registerName("pageAtIndex:") orelse
329 return BridgeError.NativeCallFailed;
330 const PageAtFn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) Id;
331 const pageAt: PageAtFn = @ptrCast(&objc.objc_msgSend);
332 if (pageAt(document, sel_page_at, @intCast(page))) |target| {
333 try setObject(view, "goToPage:", target);
334 }
335 }
336
337 return view;
338}
339
340/// A full-screen `UIViewController` hosting `view`, with a close button, +1.
341fn buildViewController(view: Id) !Id {
342 const UIViewController = objc.objc_getClass("UIViewController") orelse
343 return BridgeError.PlatformNotSupported;
344 const controller = objc.allocInit(UIViewController) catch return BridgeError.NativeCallFailed;
345 errdefer release(controller);
346
347 try setObject(controller, "setView:", view);
348 try setLong(controller, "setModalPresentationStyle:", modal_presentation_full_screen);
349
350 if (systemBackgroundColor()) |colour| try setObject(view, "setBackgroundColor:", colour);
351
352 try addCloseButton(view);
353 return controller;
354}
355
356fn systemBackgroundColor() ?Id {
357 const UIColor = objc.objc_getClass("UIColor") orelse return null;
358 const sel = objc.sel_registerName("systemBackgroundColor") orelse return null;
359 return objc.msgSendId(UIColor, sel);
360}
361
362/// The only way out of a full-screen presentation that has no swipe-to-dismiss.
363fn addCloseButton(parent: Id) !void {
364 const UIButton = objc.objc_getClass("UIButton") orelse return BridgeError.PlatformNotSupported;
365 const sel_with_type = objc.sel_registerName("buttonWithType:") orelse
366 return BridgeError.NativeCallFailed;
367 const ButtonFn = *const fn (objc.Class, objc.SEL, c_long) callconv(.c) Id;
368 const buttonWithType: ButtonFn = @ptrCast(&objc.objc_msgSend);
369 const button = buttonWithType(UIButton, sel_with_type, button_type_system) orelse
370 return BridgeError.NativeCallFailed;
371
372 if (systemImage("xmark.circle.fill")) |image| {
373 const sel_set_image = objc.sel_registerName("setImage:forState:") orelse
374 return BridgeError.NativeCallFailed;
375 const SetImageFn = *const fn (Id, objc.SEL, Id, c_ulong) callconv(.c) void;
376 const setImage: SetImageFn = @ptrCast(&objc.objc_msgSend);
377 setImage(button, sel_set_image, image, control_state_normal);
378 }
379
380 if (systemGrayColor()) |colour| try setObject(button, "setTintColor:", colour);
381
382 const target = try closeTarget();
383 const sel_add_target = objc.sel_registerName("addTarget:action:forControlEvents:") orelse
384 return BridgeError.NativeCallFailed;
385 const sel_action = objc.sel_registerName(close_selector) orelse
386 return BridgeError.NativeCallFailed;
387 const AddTargetFn = *const fn (Id, objc.SEL, Id, objc.SEL, c_ulong) callconv(.c) void;
388 const addTarget: AddTargetFn = @ptrCast(&objc.objc_msgSend);
389 addTarget(button, sel_add_target, target, sel_action, control_event_touch_up_inside);
390
391 try setBool(button, "setTranslatesAutoresizingMaskIntoConstraints:", false);
392 try setObject(parent, "addSubview:", button);
393 try pinCloseButton(parent, button);
394}
395
396fn systemImage(comptime name: [*:0]const u8) ?Id {
397 const UIImage = objc.objc_getClass("UIImage") orelse return null;
398 const sel = objc.sel_registerName("systemImageNamed:") orelse return null;
399 const NSString = objc.objc_getClass("NSString") orelse return null;
400 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return null;
401 const ns_name = objc.msgSendId1(NSString, sel_string, name) orelse return null;
402 return objc.msgSendId1(UIImage, sel, ns_name);
403}
404
405fn systemGrayColor() ?Id {
406 const UIColor = objc.objc_getClass("UIColor") orelse return null;
407 const sel = objc.sel_registerName("systemGrayColor") orelse return null;
408 return objc.msgSendId(UIColor, sel);
409}
410
411/// Swift's four constraints: inset from the safe area's top, inset from the
412/// trailing edge, and a fixed 32-point square.
413fn pinCloseButton(parent: Id, button: Id) !void {
414 const sel_safe_area = objc.sel_registerName("safeAreaLayoutGuide") orelse
415 return BridgeError.NativeCallFailed;
416 const safe_area = objc.msgSendId(parent, sel_safe_area) orelse
417 return BridgeError.NativeCallFailed;
418
419 const top = try constraintToAnchor(button, "topAnchor", safe_area, "topAnchor", close_button_inset);
420 const trailing = try constraintToAnchor(button, "trailingAnchor", parent, "trailingAnchor", -close_button_inset);
421 const width = try constraintToConstant(button, "widthAnchor", close_button_size);
422 const height = try constraintToConstant(button, "heightAnchor", close_button_size);
423
424 var items = [_]Id{ top, trailing, width, height };
425 const NSArray = objc.objc_getClass("NSArray") orelse return BridgeError.NativeCallFailed;
426 const sel_with_objects = objc.sel_registerName("arrayWithObjects:count:") orelse
427 return BridgeError.NativeCallFailed;
428 const ArrayFn = *const fn (objc.Class, objc.SEL, [*]Id, c_ulong) callconv(.c) Id;
429 const arrayWith: ArrayFn = @ptrCast(&objc.objc_msgSend);
430 const array = arrayWith(NSArray, sel_with_objects, &items, items.len) orelse
431 return BridgeError.NativeCallFailed;
432
433 const NSLayoutConstraint = objc.objc_getClass("NSLayoutConstraint") orelse
434 return BridgeError.NativeCallFailed;
435 const sel_activate = objc.sel_registerName("activateConstraints:") orelse
436 return BridgeError.NativeCallFailed;
437 objc.msgSendVoid1(NSLayoutConstraint, sel_activate, array);
438}
439
440fn constraintToAnchor(
441 view: Id,
442 comptime anchor: [*:0]const u8,
443 other: Id,
444 comptime other_anchor: [*:0]const u8,
445 constant: f64,
446) !Id {
447 const a = try anchorOf(view, anchor);
448 const b = try anchorOf(other, other_anchor);
449 const sel = objc.sel_registerName("constraintEqualToAnchor:constant:") orelse
450 return BridgeError.NativeCallFailed;
451 const Fn = *const fn (Id, objc.SEL, Id, f64) callconv(.c) Id;
452 const f: Fn = @ptrCast(&objc.objc_msgSend);
453 return f(a, sel, b, constant) orelse BridgeError.NativeCallFailed;
454}
455
456fn constraintToConstant(view: Id, comptime anchor: [*:0]const u8, constant: f64) !Id {
457 const a = try anchorOf(view, anchor);
458 const sel = objc.sel_registerName("constraintEqualToConstant:") orelse
459 return BridgeError.NativeCallFailed;
460 const Fn = *const fn (Id, objc.SEL, f64) callconv(.c) Id;
461 const f: Fn = @ptrCast(&objc.objc_msgSend);
462 return f(a, sel, constant) orelse BridgeError.NativeCallFailed;
463}
464
465fn anchorOf(view: Id, comptime anchor: [*:0]const u8) !Id {
466 const sel = objc.sel_registerName(anchor) orelse return BridgeError.NativeCallFailed;
467 return objc.msgSendId(view, sel) orelse BridgeError.NativeCallFailed;
468}
469
470// ---------------------------------------------------------------------------
471// The close button's target
472// ---------------------------------------------------------------------------
473
474const close_class_name = "CraftPdfCloser";
475/// One argument, so the existing `enc.void_one_object` covers it: UIKit passes
476/// the sender to a one-argument action. A zero-argument action would need an
477/// encoding `ios_delegate` does not carry, for no gain.
478const close_selector = "craftDismissPDF:";
479
480export fn craftPdfCloserDismiss(_: objc.id, _: objc.SEL, _: objc.id) callconv(.c) void {
481 dismissPresented();
482}
483
484/// The retained instance the button targets, built once.
485fn closeTarget() !Id {
486 if (close_target) |existing| return existing;
487
488 const class = ios_delegate.defineClass(close_class_name, "NSObject", &.{
489 .{
490 .selector = close_selector,
491 .imp = @ptrCast(&craftPdfCloserDismiss),
492 .types = ios_delegate.enc.void_one_object,
493 },
494 }) catch |err| {
495 std.log.warn("openPDF: could not build the close button's target: {}", .{err});
496 return BridgeError.NativeCallFailed;
497 };
498
499 // +1 and never released: `addTarget:` does not retain, and one instance
500 // serves every presentation for the life of the process.
501 close_target = ios_delegate.instantiate(class) catch |err| {
502 std.log.warn("openPDF: could not instantiate the close button's target: {}", .{err});
503 return BridgeError.NativeCallFailed;
504 };
505 return close_target;
506}
507
508/// Dismiss whatever this module has presented, from either route.
509///
510/// Shared by `closePDF` and the button, and idempotent: Swift's
511/// `pdfViewController?.dismiss(); pdfViewController = nil` is the same pair,
512/// and clearing first is what stops a second tap dismissing a controller that
513/// has already gone.
514fn dismissPresented() void {
515 const controller = presented_controller orelse return;
516 presented_controller = null;
517
518 const sel = objc.sel_registerName("dismissViewControllerAnimated:completion:") orelse return;
519 const DismissFn = *const fn (Id, objc.SEL, bool, Id) callconv(.c) void;
520 const dismiss: DismissFn = @ptrCast(&objc.objc_msgSend);
521 dismiss(controller, sel, true, null);
522
523 release(controller);
524}
525
526fn setObject(target: Id, comptime selector: [*:0]const u8, value: Id) !void {
527 const sel = objc.sel_registerName(selector) orelse return BridgeError.NativeCallFailed;
528 objc.msgSendVoid1(target, sel, value);
529}
530
531fn setBool(target: Id, comptime selector: [*:0]const u8, value: bool) !void {
532 const sel = objc.sel_registerName(selector) orelse return BridgeError.NativeCallFailed;
533 const Fn = *const fn (Id, objc.SEL, bool) callconv(.c) void;
534 const f: Fn = @ptrCast(&objc.objc_msgSend);
535 f(target, sel, value);
536}
537
538fn setLong(target: Id, comptime selector: [*:0]const u8, value: c_long) !void {
539 const sel = objc.sel_registerName(selector) orelse return BridgeError.NativeCallFailed;
540 const Fn = *const fn (Id, objc.SEL, c_long) callconv(.c) void;
541 const f: Fn = @ptrCast(&objc.objc_msgSend);
542 f(target, sel, value);
543}
544
545fn release(object: Id) void {
546 const target = object orelse return;
547 const sel = objc.sel_registerName("release") orelse return;
548 objc.msgSend(target, sel);
549}
550
551const testing = std.testing;
552
553test "the action names match the Swift case labels exactly" {
554 try testing.expectEqualStrings("openPDF", A.open_pdf);
555 try testing.expectEqualStrings("closePDF", A.close_pdf);
556}
557
558test "closePDF resolves the bare JSON true" {
559 try testing.expectEqualStrings("true", close_reply);
560}
561
562test "the UIKit and PDFKit constants are the ones Swift names" {
563 // Each of these is an integer passed to a setter that accepts any
564 // integer, so a wrong value configures something plausible and different:
565 // display mode 0 is single-page rather than continuous, and a modal
566 // presentation style of 2 is a sheet that *can* be swiped away, which
567 // would quietly make the close button optional.
568 try testing.expectEqual(@as(c_long, 1), display_mode_single_page_continuous);
569 try testing.expectEqual(@as(c_long, 0), display_direction_vertical);
570 try testing.expectEqual(@as(c_long, 0), modal_presentation_full_screen);
571 try testing.expectEqual(@as(c_long, 1), button_type_system);
572 try testing.expectEqual(@as(c_ulong, 64), control_event_touch_up_inside);
573}
574
575test "the data prefix is stripped everywhere it appears, as Swift strips it" {
576 // `replacingOccurrences(of:with:)` is not `hasPrefix`. Copying the loose
577 // behaviour matters because the *observable* consequence is which sources
578 // are refused, and a stricter prefix-only strip would accept a source with
579 // a repeated header that Swift turns into garbage and rejects.
580 const one = try stripDataPrefix(testing.allocator, data_url_prefix ++ "QUJD");
581 defer testing.allocator.free(one);
582 try testing.expectEqualStrings("QUJD", one);
583
584 const twice = try stripDataPrefix(testing.allocator, data_url_prefix ++ "AA" ++ data_url_prefix ++ "BB");
585 defer testing.allocator.free(twice);
586 try testing.expectEqualStrings("AABB", twice);
587
588 // The sharp edge: `data:` prefix, different media type. Nothing is
589 // stripped, so the header stays in the base64 and the decode fails —
590 // which is exactly the refusal Swift produces.
591 const wrong_type = try stripDataPrefix(testing.allocator, "data:image/png;base64,QUJD");
592 defer testing.allocator.free(wrong_type);
593 try testing.expectEqualStrings("data:image/png;base64,QUJD", wrong_type);
594
595 const untouched = try stripDataPrefix(testing.allocator, "https://example.com/a.pdf");
596 defer testing.allocator.free(untouched);
597 try testing.expectEqualStrings("https://example.com/a.pdf", untouched);
598}
599
600test "source is required and page defaults to zero" {
601 // Swift's arm has no else, so a missing source settles nothing at all.
602 const ok = try readRequest(testing.allocator, "{\"source\":\"https://a/b.pdf\"}");
603 testing.allocator.free(ok.source);
604 try testing.expectEqual(@as(i64, 0), ok.page);
605
606 const paged = try readRequest(testing.allocator, "{\"source\":\"x\",\"page\":3}");
607 testing.allocator.free(paged.source);
608 try testing.expectEqual(@as(i64, 3), paged.page);
609
610 try testing.expectError(BridgeError.MissingData, readRequest(testing.allocator, "{}"));
611 try testing.expectError(
612 BridgeError.InvalidParameter,
613 readRequest(testing.allocator, "{\"source\":\"\"}"),
614 );
615 try testing.expectError(
616 BridgeError.InvalidParameter,
617 readRequest(testing.allocator, "{\"source\":5}"),
618 );
619 // A non-integer page is refused rather than rewritten to 0, which is where
620 // this diverges from `as? Int ?? 0` silently substituting.
621 try testing.expectError(
622 BridgeError.InvalidParameter,
623 readRequest(testing.allocator, "{\"source\":\"x\",\"page\":\"2\"}"),
624 );
625 try testing.expectError(BridgeError.InvalidJSON, readRequest(testing.allocator, "[]"));
626}
627
628test "every declared action dispatches to something" {
629 var bridge = PdfBridge.init(testing.allocator);
630 defer bridge.deinit();
631
632 for (capability_actions) |decl| {
633 bridge.handleMessage(decl.name, "{}") catch |err| {
634 try testing.expect(err != BridgeError.UnknownAction);
635 continue;
636 };
637 }
638 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}"));
639}
640
641test "dismissing when nothing is presented is a no-op" {
642 // Both `closePDF` and the close button land here, and Swift's
643 // `pdfViewController?.dismiss()` is a no-op on nil. Clearing before
644 // dismissing is what stops a second tap messaging a controller that has
645 // already gone.
646 presented_controller = null;
647 dismissPresented();
648 dismissPresented();
649 try testing.expect(presented_controller == null);
650}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -419,6 +419,7 @@ pub fn gateFor(action: []const u8) ?Feature {
419419 .{ "haptic", .haptics },
420420 .{ "lockOrientation", .orientation_lock },
421421 .{ "openCamera", .camera },
422 .{ "openPDF", .pdf_viewer },
422423 .{ "pickContact", .contacts },
423424 .{ "pickFile", .file_picker },
424425 .{ "pickImage", .camera },
packages/zig/src/ios_dispatch.zigmodified+2-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -35,6 +35,7 @@ const bridge_mobile_contacts = ;
3535const bridge_mobile_vision = ;
3636const bridge_mobile_auth = ;
3737const bridge_mobile_siri = ;
38const bridge_mobile_pdf = ;
3839// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
3940// capability_actions table is empty — the two speech actions were researched
4041// in full and left with the Swift shim — so a module here would iterate,
@@ -279,6 +280,7 @@ const mobile_bridges = .{
279280 bridge_mobile_vision.VisionBridge,
280281 bridge_mobile_auth.AuthBridge,
281282 bridge_mobile_siri.SiriBridge,
283 bridge_mobile_pdf.PdfBridge,
282284};
283285
284286/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+2-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -52,6 +52,7 @@ const zig_sources = [_][]const u8{
5252 @embedFile("src/bridge_mobile_vision.zig"),
5353 @embedFile("src/bridge_mobile_auth.zig"),
5454 @embedFile("src/bridge_mobile_siri.zig"),
55 @embedFile("src/bridge_mobile_pdf.zig"),
5556 @embedFile("src/bridge_mobile_speech.zig"),
5657};
5758
@@ -96,7 +97,7 @@ const dispatch_end = "func webView(";
9697/// 48 after calendar and contacts. Speech was researched in full and left
9798/// with the shim: neither action replies, so unlike the earlier deferrals
9899/// there is no promise to strand and falling through costs the page nothing.
99const max_not_yet_migrated: usize = 40;
100const max_not_yet_migrated: usize = 38;
100101
101102fn dispatcherRegion() []const u8 {
102103 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";