| @@ -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 | + |
| 62 | +const std = ; |
| 63 | +const builtin = ; |
| 64 | +const capabilities = ; |
| 65 | +const bridge_error = ; |
| 66 | +const objc_runtime = ; |
| 67 | +const ios_delegate = ; |
| 68 | +const bridge_mobile_system = ; |
| 69 | + |
| 70 | +const objc = objc_runtime.objc; |
| 71 | +const BridgeError = bridge_error.BridgeError; |
| 72 | +const Id = ?*anyopaque; |
| 73 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 74 | + |
| 75 | +pub const A = struct { |
| 76 | + pub const open_pdf = "openPDF"; |
| 77 | + pub const close_pdf = "closePDF"; |
| 78 | +}; |
| 79 | + |
| 80 | +pub 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. |
| 86 | +const data_url_prefix = "data:application/pdf;base64,"; |
| 87 | + |
| 88 | +/// `kPDFDisplaySinglePageContinuous`. |
| 89 | +const display_mode_single_page_continuous: c_long = 1; |
| 90 | +/// `kPDFDisplayDirectionVertical`. |
| 91 | +const display_direction_vertical: c_long = 0; |
| 92 | +/// `UIModalPresentationFullScreen`. |
| 93 | +const modal_presentation_full_screen: c_long = 0; |
| 94 | +/// `UIButtonTypeSystem`. |
| 95 | +const button_type_system: c_long = 1; |
| 96 | +/// `UIControlEventTouchUpInside`. |
| 97 | +const control_event_touch_up_inside: c_ulong = 1 << 6; |
| 98 | +/// `UIControlStateNormal`. |
| 99 | +const control_state_normal: c_ulong = 0; |
| 100 | + |
| 101 | +const close_button_inset: f64 = 16; |
| 102 | +const close_button_size: f64 = 32; |
| 103 | + |
| 104 | +/// `closePDF`'s reply: the bare JSON `true`, per `.fragmentsAllowed`. |
| 105 | +const 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. |
| 112 | +var presented_controller: Id = null; |
| 113 | +var close_target: Id = null; |
| 114 | + |
| 115 | +pub 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 | + |
| 191 | +const 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. |
| 202 | +fn 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. |
| 236 | +fn 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 | + |
| 255 | +fn 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 | + |
| 275 | +fn 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. |
| 287 | +fn 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 | + |
| 300 | +fn 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. |
| 308 | +fn 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. |
| 341 | +fn 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 | + |
| 356 | +fn 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. |
| 363 | +fn 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 | + |
| 396 | +fn 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 | + |
| 405 | +fn 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. |
| 413 | +fn 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 | + |
| 440 | +fn 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 | + |
| 456 | +fn 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 | + |
| 465 | +fn 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 | + |
| 474 | +const 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. |
| 478 | +const close_selector = "craftDismissPDF:"; |
| 479 | + |
| 480 | +export fn craftPdfCloserDismiss(_: objc.id, _: objc.SEL, _: objc.id) callconv(.c) void { |
| 481 | + dismissPresented(); |
| 482 | +} |
| 483 | + |
| 484 | +/// The retained instance the button targets, built once. |
| 485 | +fn 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. |
| 514 | +fn 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 | + |
| 526 | +fn 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 | + |
| 531 | +fn 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 | + |
| 538 | +fn 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 | + |
| 545 | +fn 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 | + |
| 551 | +const testing = std.testing; |
| 552 | + |
| 553 | +test "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 | + |
| 558 | +test "closePDF resolves the bare JSON true" { |
| 559 | + try testing.expectEqualStrings("true", close_reply); |
| 560 | +} |
| 561 | + |
| 562 | +test "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 | + |
| 575 | +test "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 | + |
| 600 | +test "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 | + |
| 628 | +test "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 | + |
| 641 | +test "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 | +} |