ReviewOS

also looking at this

craft-native/craft

iOS Phase 8: the presented pickers — ratchet 58 → 53, last mechanism landed

#90
Merged glennmichael123 wants to merge feat/ios-phase8-ui into main
11 files +5752 -12
packages/ios/fixtures/zig-slice/build-and-run.shmodified+10-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -90,7 +90,7 @@ LAUNCH_PID=$!
9090# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
9191# fixed amount, so a slow boot does not read as a failure.
9292for _ in $(seq 1 60); do
93 if grep -q 'i=12' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi
93 if grep -q 'i=18' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi
9494 sleep 1
9595done
9696kill "$LAUNCH_PID" 2>/dev/null || true
@@ -172,4 +172,13 @@ C60="$(count 60)"
172172[ "$C60" -ge 1 ] || { echo "FAIL: startMotionUpdates never reached the dispatcher"; exit 1; }
173173echo "ok: event-driven action dispatched to Zig (i=60 seen ${C60}x)"
174174
175C18="$(count 18)"
176# The fixture has no NSCameraUsageDescription, and presenting a camera picker
177# without one TERMINATES the process — so the module refuses before it ever
178# asks whether hardware exists, with PERMISSION_DENIED. What is asserted
179# is that the code is specific: the generic NATIVE_CALL_FAILED would send
180# whoever reads it looking for a bug that is not there.
181[ "$C18" -ge 1 ] || { echo "FAIL: the refusal did not name its own cause"; exit 1; }
182echo "ok: unavailable hardware refused with a specific code (i=18 seen ${C18}x)"
183
175184echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+8-2
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -45,11 +45,15 @@ const char craft_slice_page[] =
4545 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n"
4646 "}\n"
4747 "\n"
48 "var sentErrorAck = false;\n"
48 "var sentErrorAck = false, sentCode = false;\n"
4949 "window.__craftBridgeError = function (ctx) {\n"
5050 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5151 " // newline intact; anything mangled and this equality fails, i=7 never\n"
5252 " // fires, and the harness reports the error route broken.\n"
53 " if (!sentCode && ctx && ctx.code === 'PERMISSION_DENIED' && ctx.id === 70) {\n"
54 " sentCode = true;\n"
55 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:18});\n"
56 " }\n"
5357 " var messageIntact = ctx && ctx.message === 'declined \\\\ \"on purpose\"\\nsecond line';\n"
5458 " if (!sentErrorAck && ctx && ctx.error === true && ctx.code === 'HOST_DECLINED'\n"
5559 " && ctx.id === 6 && messageIntact) {\n"
@@ -123,7 +127,9 @@ const char craft_slice_page[] =
123127 " bridge.postMessage({t:'mobile', a:'getPendingNotifications', i:50});\n" " // Motion is unavailable on a simulator, so this must REJECT — an\n"
124128 " // honest refusal, not a fabricated stream of zeros. i=16 stays unsent,\n"
125129 " // which is the assertion.\n"
126 " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n"
130 " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n" " // No camera on a simulator. The refusal must name its own cause —\n"
131 " // a specific code, not the generic native-failure catch-all.\n"
132 " bridge.postMessage({t:'mobile', a:'openCamera', i:70});\n"
127133 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
128134 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
129135 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+9-0
Changes to packages/zig/build.zig
@@ -458,6 +458,15 @@ pub fn build(b: *std.Build) void {
458458 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_motion.zig", .{
459459 .root_source_file = b.path("src/bridge_mobile_motion.zig"),
460460 });
461 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_imagepicker.zig", .{
462 .root_source_file = b.path("src/bridge_mobile_imagepicker.zig"),
463 });
464 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_filepicker.zig", .{
465 .root_source_file = b.path("src/bridge_mobile_filepicker.zig"),
466 });
467 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_contactpicker.zig", .{
468 .root_source_file = b.path("src/bridge_mobile_contactpicker.zig"),
469 });
461470
462471 const menubar_tests = b.addTest(.{
463472 .root_module = b.createModule(.{
packages/zig/src/bridge_mobile_contactpicker.zigadded+1809-0
Changes to packages/zig/src/bridge_mobile_contactpicker.zig
@@ -0,0 +1,1809 @@
1//! `pickContact` — present `CNContactPickerViewController` and answer with what
2//! the user picked.
3//!
4//! One action, one payload field, and four delegate methods — Swift's three,
5//! plus one belt for a dismissal Swift has no answer to. The Swift
6//! original is `CraftApp.swift:969-973` (dispatch), `4301-4323` (presentation)
7//! and `5088-5136` (the `CNContactPickerDelegate` extension plus
8//! `formatContact`). Everything below is measured against those lines.
9//!
10//! ## The payload: one field, `multiple`
11//!
12//! The injected JS (`CraftApp.swift:1970-1978`) posts
13//! `{action:'pickContact', multiple: options.multiple || false, callbackId: id}`,
14//! so `multiple` is a **top-level** field of `d`. Note what `||` does and does
15//! not do: it is not a boolean coercion, it is a fallback. `pickContact({multiple:1})`
16//! puts the number `1` on the wire and `pickContact({multiple:'yes'})` puts the
17//! string `"yes"`.
18//!
19//! Swift then reads `body["multiple"] as? Bool ?? false`. `as? Bool` on the
20//! bridged `NSNumber` succeeds for exactly `0` and `1` (→ false/true) and
21//! returns nil for `2`, for `0.5`, for a string, for anything else — which the
22//! `?? false` turns into false. `multipleFrom` below is that rule, spelled out,
23//! and it is pinned by tests rather than left to read like a coercion.
24//!
25//! There is **no other field**. `callbackId` is the shim's own correlation and
26//! never reaches a Zig module — `ios_async` owns the request id here.
27//!
28//! **The page's promise has no timeout.** `pickContact` builds its `Promise`
29//! by hand instead of going through `_createCallback` (`CraftApp.swift:1196`,
30//! which carries the 30-second `setTimeout`). An unanswered call parks the page
31//! forever, which is why every path in this file ends in a reply or an error.
32//!
33//! ## The replies, which are three different shapes
34//!
35//! - **one contact** (`contactPicker:didSelectContact:`) resolves a JSON
36//! *object*: `{"id","givenName","familyName","displayName","phoneNumbers",
37//! "emailAddresses"}`, where `phoneNumbers` is an array of
38//! `{"label","number"}` and `emailAddresses` an array of
39//! `{"label","address"}`. Swift builds a `[String: Any]`, whose key order is
40//! a `Dictionary` and therefore arbitrary; one order is fixed here so the
41//! bytes are testable, the same call `bridge_mobile_notifications.zig`
42//! makes.
43//! - **many contacts** (`contactPicker:didSelectContacts:`) resolves a JSON
44//! *array* of those objects. `contacts.map` over an empty selection is `[]`,
45//! and that **resolves** — it is not an error and not a cancel.
46//! `test-bridges.html:1030` reads `contacts?.length`, so it must be an
47//! array.
48//! - **cancel** (`contactPickerDidCancel:`) **rejects**. This is the one
49//! action in this batch where a cancel is an error rather than a distinct
50//! resolve, so there is deliberately no `{"cancelled":true}` here — that
51//! would be a fabricated success for a pick that never happened.
52//!
53//! `packages/typescript/types/craft.d.ts:1157-1164` declares `Contact` with
54//! `phoneNumbers: string[]` / `emailAddresses: string[]`. That matches
55//! `getContacts` (flat `phone.value.stringValue` strings) and is **wrong for
56//! this action**, which emits `{label, number}` / `{label, address}` dicts. The
57//! runtime shape is the contract; `formatContact` is what is mirrored here, not
58//! the type declaration.
59//!
60//! ## What diverges, stated rather than smoothed over
61//!
62//! **The cancel error text and code change.** Swift calls
63//! `rejectCallback(id, error: "Cancelled")` with the default
64//! `code: "CRAFT_ERROR"`, which reaches the page as
65//! `{"error":true,"code":"CRAFT_ERROR",…,"message":"Cancelled"}`. Zig replies
66//! through `ios_async.deliverError`, which is hard-wired to
67//! `BridgeError.NativeCallFailed` → `code:"NATIVE_CALL_FAILED"`,
68//! `message:"Native API call failed"`. The page's `catch` still runs and the
69//! promise still settles, so nothing hangs and nothing is fabricated — but a
70//! page that prints `err.message` (which `test-bridges.html:1022` does) shows
71//! different text, and a page that branches on `err.code` sees a different code.
72//!
73//! This is the honest floor of the available channel, not a preference.
74//! `bridge_error.BridgeError.Cancelled` → `CANCELLED` /
75//! `"Operation was cancelled"` would be *nearer* the truth, and `ios_async` has
76//! no way to send it: `deliverError` sets a `failed` flag and `deliverOnMain`
77//! chooses `NativeCallFailed` for every flagged slot. Closing the gap is a
78//! change to `ios_async.zig` — a `BridgeError` stored on the slot beside
79//! `failed`, and `deliverOnMain` sending that instead of the constant — and it
80//! belongs there rather than in a second reply path built here, because a
81//! module that reached around `ios_async` would also be reaching around the
82//! generation guard that stops a stale callback answering someone else's call.
83//! Until then, "the native call failed" is what a cancel says, and saying so
84//! here is the point.
85//!
86//! **`config.enableContacts` has no Zig mirror.** `ios.zig`'s `AppConfig` has
87//! no such field — the same gap `bridge_mobile_system.zig` records for
88//! `enableShare`. The Info.plist is read instead, and for this flag the mapping
89//! is exact: `packages/ios/src/index.ts:189` writes `NSContactsUsageDescription`
90//! if and only if `config.enableContacts`, with none of the sharing that made
91//! the location keys ambiguous.
92//!
93//! Two things about that gate have to be said plainly. First, the key is **not
94//! a precondition of the API**: `CNContactPickerViewController` runs out of
95//! process and needs no Contacts authorization, so this reads the key purely as
96//! *evidence of the flag*, never as something the framework requires. Second,
97//! the behaviour changes: Swift's `case "pickContact":` has **no `else`**, so an
98//! app built with `enableContacts: false` answers the page with *nothing*, on a
99//! promise with no timeout — a hang for the life of the page. Here it is an
100//! explicit `PERMISSION_DENIED`. Everybody settles, which is strictly better,
101//! and it is a difference a page can observe.
102//!
103//! **No permission is requested, deliberately.** `pickContact` never touches
104//! `CNContactStore` and never calls `requestAccess(for:.contacts)`; only
105//! `getContacts` and `addContact` do. Adding one here would show the user a
106//! prompt the shim never shows.
107//!
108//! **Nothing is dismissed, and that is a decision rather than an omission.**
109//! What is *verified*: `CraftApp.swift` contains exactly five `dismiss(` calls —
110//! `UIImagePickerController` (2643, 2662), the PDF viewer (4288, 4294) and the
111//! data scanner (4873) — and none of them is in the contact-picker path. Swift's
112//! three `CNContactPickerDelegate` methods return without dismissing anything.
113//! What is *reasoned*: `CNContactPickerViewController` is an out-of-process
114//! remote view controller, which is the standard explanation for why it takes
115//! itself down on both Done and Cancel and why its delegate protocol asks for no
116//! dismissal.
117//!
118//! So this file matches the shim exactly, and matching it is the conservative
119//! choice in both directions. Adding a dismissal would be a *second* dismissal
120//! of a controller already dismissing, and sent to the presenter it would take
121//! down whatever else happened to be modal — a new bug, in an action the shim
122//! answers. Omitting it can only reproduce a defect the shim already has. This
123//! is the one place where the generic picker rule "the delegate must dismiss"
124//! does not apply; the image and document pickers are `UIImagePickerController`
125//! and `UIDocumentPickerViewController`, both in-process, both of which do need
126//! it.
127//!
128//! Not verified on a device from here: that the picker really does disappear
129//! without help. If it turns out not to, the fix is one `dismiss` sent to the
130//! *picker* (never the presenter) from each of the picker callbacks.
131//!
132//! **A contact whose keys were not fetched still crashes.** Touching an
133//! unfetched property raises `CNContactPropertyNotFetchedException`, which from
134//! Zig is an uncatchable SIGABRT. Swift cannot catch `NSException` either, so
135//! this is exact parity with the shim rather than a regression — and there is
136//! deliberately no comment below claiming a guard against it, because there
137//! isn't one.
138//!
139//! ## Concurrency: one picker, one ticket, and a refusal that says so
140//!
141//! Swift keeps a single `pendingCallbackId` (line 383) shared by *every* picker
142//! in the coordinator — camera, document, NFC, Bluetooth, contacts. Opening any
143//! second picker overwrites it and the first promise never settles. This module
144//! keeps its own ticket, and when a second `pickContact` arrives while one is on
145//! screen it **refuses the second and leaves the first alone**: the first picker
146//! is still presented and will still answer, so displacing it would strand a
147//! call that was about to succeed.
148//!
149//! What that costs, said rather than hidden: the slot is cleared only by a
150//! delegate callback. A picker that is never presented, or presented and then
151//! never answered, leaves the slot held for the life of the process and refuses
152//! every later call. Two causes are known, and they are not both removed:
153//!
154//! - **Presenting on a controller that is already presenting**, which UIKit
155//! answers by logging and doing nothing. That one is removed:
156//! `bridge_mobile_system.uikit.topmostViewController()`'s walk up
157//! `presentedViewController` is why this file reuses that function rather
158//! than copying Swift's `windows.first?.rootViewController`
159//! (`CraftApp.swift:4303-4304`), which has the bug.
160//! - **A swipe-down dismissal.** The picker is a sheet on iOS 13+, and an
161//! interactive dismissal is widely reported not to call
162//! `contactPickerDidCancel:` — the same report
163//! `bridge_mobile_imagepicker.zig` carries a belt for, and one this repo
164//! verifies on no device. `presentationControllerDidDismiss:` is that belt
165//! here too: it is registered on the delegate, and the picker's
166//! presentation controller is pointed at that delegate before presenting,
167//! so a swipe rejects the call instead of stranding it. Belt, not fix —
168//! UIKit sends it only for a user-driven dismissal, so it cannot fire after
169//! a pick or a Cancel that already answered, and if the report is wrong it
170//! costs two messages. The one case it does not cover is a presentation
171//! controller that already carries a delegate of its own, which is left
172//! alone rather than displaced; that is logged where it happens rather than
173//! assumed away.
174//!
175//! No reply is ever sent from a delegate callback directly. `ios_async` is the
176//! only channel, and the main-queue hop it performs is not merely thread
177//! safety: it is what restores the `request_context` captured at dispatch, so
178//! the reply names the call that is waiting instead of falling back to
179//! action-name matching.
180
181const std = ;
182const builtin = ;
183const capabilities = ;
184const bridge_error = ;
185const objc_runtime = ;
186const ios_async = ;
187const ios_delegate = ;
188const bridge_mobile_system = ;
189const compat_mutex = ;
190
191const objc = objc_runtime.objc;
192const is_darwin = builtin.target.os.tag.isDarwin();
193
194/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
195/// location/notifications precedent: `objc_runtime.objc` is an empty struct off
196/// Darwin and a function *signature* is analysed even when a comptime platform
197/// guard prunes the body, so naming `objc.id` in the `callconv(.c)` types below
198/// would break the host build. A single optional pointer, never `?objc.id` — a
199/// double optional is illegal in `callconv(.c)`.
200const Id = ?*anyopaque;
201
202/// The action name, spelled exactly as the Swift `case` label spells it.
203/// `test/ios_conformance_test.zig` matches this block against those labels in
204/// both directions, and fails the build if two modules declare one name.
205pub const A = struct {
206 pub const pick_contact = "pickContact";
207};
208
209/// `.result`: every Swift path out of this action terminates in exactly one
210/// `resolveCallback` (either `didSelect` overload) or one `rejectCallback`
211/// (cancel). `.none` would be a claim that nothing answers, and on this
212/// particular promise — hand-built, no `setTimeout` — that is a page parked
213/// forever rather than for thirty seconds.
214///
215/// `.live`, not `.unavailable`: in an app built with `enableContacts` the
216/// picker presents and answers. A refusal here is always a specific condition
217/// (contacts not configured, ContactsUI not linked, a picker already on screen,
218/// the async pool full), never this action's normal answer.
219pub const capability_actions = [_]capabilities.ActionDecl{
220 .{ .name = A.pick_contact, .reply = .result },
221};
222
223/// Which handler an action selects, split out from `handleMessage` so the
224/// table-versus-dispatch agreement is assertable on a host without touching
225/// ContactsUI.
226const Route = enum { pick_contact };
227
228fn routeFor(action: []const u8) ?Route {
229 if (std.mem.eql(u8, action, A.pick_contact)) return .pick_contact;
230 return null;
231}
232
233pub const ContactPickerBridge = struct {
234 allocator: std.mem.Allocator,
235
236 const Self = @This();
237
238 pub fn init(allocator: std.mem.Allocator) Self {
239 return .{ .allocator = allocator };
240 }
241
242 pub fn deinit(_: *Self) void {}
243
244 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
245 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
246 // Exhaustive, so a `Route` without a handler is a compile error.
247 return switch (route) {
248 .pick_contact => self.pickContact(data),
249 };
250 }
251
252 /// Present the contact picker and hand the answer to the delegate.
253 ///
254 /// Ordering is load-bearing in two places. The payload is parsed *first*, so
255 /// a malformed body is reported the same on any platform and before any
256 /// framework is consulted. And every fallible step — the Info.plist gate,
257 /// both framework classes, every selector a delegate callback or the
258 /// swipe-down belt will need, the delegate class, the presenter, the
259 /// predicate, the picker itself — runs **before** `ios_async.acquire`.
260 /// Exactly one path leaves this function after the lease without presenting
261 /// — the concurrency refusal — and it releases the slot with
262 /// `ios_async.abandon` on its way out. Every other statement between the
263 /// lease and `presentViewController:` is infallible. That is the property
264 /// being protected: a lease that escapes without either a presentation or an
265 /// `abandon` narrows the pool for the life of the process.
266 fn pickContact(self: *Self, data: []const u8) !void {
267 const multiple = try parseMultiple(self.allocator, data);
268
269 if (!is_darwin) return error.UnsupportedPlatform;
270
271 try requireContactsConfigured();
272
273 // ContactsUI first, Contacts second, each guarded on its own: a process
274 // that linked Contacts but not ContactsUI is a real configuration, and
275 // the fixture (`packages/ios/fixtures/zig-slice/build-and-run.sh:65`)
276 // links neither. Naming the missing class beats a generic failure,
277 // because the fix is a link line rather than anything in this file.
278 const PickerClass = objc.objc_getClass("CNContactPickerViewController") orelse {
279 std.log.err(
280 "pickContact refused: this process has no CNContactPickerViewController; " ++
281 "ContactsUI.framework is not linked",
282 .{},
283 );
284 return bridge_error.BridgeError.PlatformNotSupported;
285 };
286
287 const sels = try Sels.resolve();
288 const delegate = try ensureDelegate();
289
290 // The walk up `presentedViewController` is the part Swift is missing —
291 // see the module comment. It also matters to the refusal policy: one of
292 // the two known ways to leave a ticket unanswered forever is a
293 // presentation UIKit silently declines because the target is already
294 // presenting, and the walk is what stops this file ever asking for that.
295 // The other — a swipe-down that sends no delegate callback — is what
296 // `attachDismissObserver` below is for.
297 const presenter = try bridge_mobile_system.uikit.topmostViewController();
298
299 const NSPredicate = objc.objc_getClass("NSPredicate") orelse return error.ClassNotFound;
300 const sel_predicate_with_value = try selector("predicateWithValue:");
301 const sel_set_delegate = try selector("setDelegate:");
302 const sel_set_enabling = try selector("setPredicateForEnablingContact:");
303 const sel_set_selection = try selector("setPredicateForSelectionOfContact:");
304 const sel_present = try selector("presentViewController:animated:completion:");
305 // The swipe-down belt's two reads, resolved here with everything else:
306 // a missing selector then fails the call rather than silently leaving
307 // the only thing that answers an interactive dismissal unattached.
308 const dismiss_sels = DismissSels{
309 .presentation = try selector("presentationController"),
310 .delegate = try selector("delegate"),
311 .set_delegate = sel_set_delegate,
312 };
313
314 // `+[NSPredicate predicateWithValue:]` takes a `BOOL`, so the cast has
315 // to name that argument; `msgSendId1` builds the signature from
316 // `@TypeOf(arg1)` and `true` is a Zig `bool`, which is what `B` is on
317 // 64-bit Apple platforms.
318 const always_true = objc.msgSendId1(NSPredicate, sel_predicate_with_value, true) orelse
319 return error.NativeCallFailed;
320
321 const picker = (try objc.allocInit(PickerClass)) orelse return error.NativeCallFailed;
322 // `allocInit` gave us +1. `presentViewController:` retains the picker for
323 // the life of the presentation, so ours is the reference to drop — and
324 // on the refusal paths below it is the only one there is. Same shape as
325 // the share sheet's `defer objc.release(activity_vc)`.
326 defer objc.release(picker);
327
328 objc.msgSendVoid1(picker, sel_set_delegate, delegate);
329 objc.msgSendVoid1(picker, sel_set_enabling, always_true);
330
331 if (multiple) {
332 // Swift's `picker.predicateForSelectionOfContact = nil`. The
333 // `@as(objc.id, null)` is required: an untyped `null` gives
334 // `msgSendVoid1` no argument type to build a signature from.
335 objc.msgSendVoid1(picker, sel_set_selection, @as(objc.id, null));
336 } else {
337 // Swift builds a second `NSPredicate(value: true)` here. One
338 // instance serves both properties identically — `NSPredicate` is
339 // immutable and neither property mutates it — so the object graph
340 // is the same one Swift assembles in two allocations.
341 objc.msgSendVoid1(picker, sel_set_selection, always_true);
342 }
343
344 // Attached before presenting, because that is when a presentation
345 // controller can be configured, and retried after `presentViewController:`
346 // for the case where UIKit only builds one there. Infallible either way:
347 // it returns whether it took, and never fails the call.
348 var dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels);
349
350 const ticket = ios_async.acquire(A.pick_contact) orelse return poolFull();
351
352 // Published *before* the framework call, never after: a delegate that
353 // somehow reached an empty slot would have no ticket to reply with. The
354 // publish is also the concurrency check, done under one lock so two
355 // dispatches cannot both find the slot free.
356 if (!publishPending(.{ .ticket = ticket, .sels = sels })) {
357 ios_async.abandon(ticket);
358 return alreadyPresented();
359 }
360
361 const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void;
362 const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend);
363 // Completion handler nil, as Swift's `present(picker, animated: true)`
364 // passes none. There is nothing to do on presentation-complete: the
365 // answer comes from the delegate.
366 present_fn(presenter, sel_present, picker, true, null);
367
368 if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels);
369 if (!dismiss_observed) {
370 std.log.warn(
371 "pickContact: the picker's presentation controller could not be observed " ++
372 "(absent, or already carrying a delegate this file will not displace); " ++
373 "if a swipe-down dismissal does not send contactPickerDidCancel: then " ++
374 "this call stays unanswered and every later one is refused",
375 .{},
376 );
377 }
378 }
379};
380
381/// The answer for a full block pool, copied from `bridge_mobile_location`:
382/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
383/// designated stand-in, and the point is that the caller gets an explicit
384/// rejection instead of a promise that never settles.
385fn poolFull() bridge_error.BridgeError {
386 std.log.warn(
387 "pickContact refused: all {d} async slots are in flight",
388 .{ios_async.max_in_flight},
389 );
390 return bridge_error.BridgeError.InvalidParameter;
391}
392
393/// The answer for a second `pickContact` while one is on screen.
394///
395/// Deliberately *not* displacement. `bridge_mobile_location.zig` rejects the
396/// displaced first caller because CoreLocation's one-shot may genuinely never
397/// answer; a presented picker is the opposite situation — it is on screen, the
398/// user is looking at it, and it will answer. Refusing the newcomer keeps that
399/// promise alive. Swift does neither: it overwrites `pendingCallbackId` and the
400/// first caller waits forever on an untimed promise.
401///
402/// Same `INVALID_PARAMETER` stand-in as `poolFull`, for the same reason —
403/// `BridgeError` has no word for "busy" — with a log line that says which of
404/// the two happened.
405fn alreadyPresented() bridge_error.BridgeError {
406 std.log.warn(
407 "pickContact refused: a contact picker is already presented and still waiting for an " ++
408 "answer; the first call is left alone rather than being stranded",
409 .{},
410 );
411 return bridge_error.BridgeError.InvalidParameter;
412}
413
414// =============================================================================
415// Payload parsing. Pure — no Objective-C — so the exact coercion Swift performs
416// is pinned by host tests on every platform.
417// =============================================================================
418
419/// `multiple`, read the way `body["multiple"] as? Bool ?? false` reads it.
420///
421/// A malformed or non-object `d` is `InvalidJSON` rather than a silent `false`.
422/// It is a Zig-only failure mode — `WKScriptMessage.body` is always a
423/// dictionary, so Swift never meets it — but guessing `false` for a body that
424/// could not be read would be acting on a default the page did not send.
425fn parseMultiple(allocator: std.mem.Allocator, data: []const u8) !bool {
426 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
427 return bridge_error.BridgeError.InvalidJSON;
428 defer parsed.deinit();
429
430 const root = switch (parsed.value) {
431 .object => |obj| obj,
432 else => return bridge_error.BridgeError.InvalidJSON,
433 };
434
435 return multipleFrom(root.get("multiple"));
436}
437
438/// The `as? Bool ?? false` rule, as a total function.
439///
440/// `options.multiple || false` in the injected JS is a fallback, not a
441/// coercion, so anything truthy reaches the wire unchanged: `1`, `"yes"`, `{}`.
442/// Swift's bridged `NSNumber as? Bool` then succeeds for exactly `0` and `1`
443/// and yields nil for everything else, which `?? false` collapses to false. So
444/// `pickContact({multiple: 2})` and `pickContact({multiple: 'yes'})` both open
445/// a *single*-selection picker, and reproducing that is the contract — reading
446/// them as "truthy, therefore multiple" would open a different picker than the
447/// shim opens for the same call.
448fn multipleFrom(value: ?std.json.Value) bool {
449 const v = value orelse return false;
450 return switch (v) {
451 .bool => |b| b,
452 .integer => |i| i == 1,
453 // `NSNumber(value: 1.0) as? Bool` is true, `0.5` is nil. Only an exact
454 // 1 is a yes.
455 .float => |f| f == 1.0,
456 // A string, an array, an object, JSON null, or a number too large for
457 // an i64 (`number_string`). None of them survive `as? Bool`.
458 else => false,
459 };
460}
461
462// =============================================================================
463// Reply shaping. Pure — no Objective-C — so the exact bytes the page receives
464// are pinned by host tests.
465// =============================================================================
466
467/// One `CNLabeledValue`, flattened.
468///
469/// `value` is the phone number's `stringValue` or the email address; which wire
470/// key it lands under — `number` or `address` — is decided by the writer, not
471/// stored here, because `formatContact` uses two different key names for the
472/// same shape and getting them swapped is silent.
473const Labeled = struct {
474 label: []const u8,
475 value: []const u8,
476};
477
478/// One contact, exactly as `formatContact` assembles it.
479///
480/// The strings borrow whatever buffer they were read from. On the native path
481/// that is an `NSString`'s internal UTF-8 buffer, valid for the delegate
482/// callback's autorelease pool, and every one of them is copied into the JSON
483/// before the callback returns.
484const Contact = struct {
485 id: []const u8,
486 given_name: []const u8,
487 family_name: []const u8,
488 display_name: []const u8,
489 phones: []const Labeled,
490 emails: []const Labeled,
491};
492
493fn appendJsonString(
494 allocator: std.mem.Allocator,
495 out: *std.ArrayListUnmanaged(u8),
496 s: []const u8,
497) !void {
498 try out.append(allocator, '"');
499 // A contact's name, label and number are user data and will contain `"` and
500 // `\` in the wild. This reply is replayed into the source
501 // `evaluateJavaScript:` parses, so an unescaped quote is a syntax error in
502 // the page rather than a wrong field.
503 try bridge_error.appendJsonEscaped(allocator, out, s);
504 try out.append(allocator, '"');
505}
506
507fn appendLabeledArray(
508 allocator: std.mem.Allocator,
509 out: *std.ArrayListUnmanaged(u8),
510 items: []const Labeled,
511 comptime value_key: []const u8,
512) !void {
513 try out.append(allocator, '[');
514 for (items, 0..) |item, i| {
515 if (i != 0) try out.append(allocator, ',');
516 try out.appendSlice(allocator, "{\"label\":");
517 try appendJsonString(allocator, out, item.label);
518 try out.appendSlice(allocator, ",\"" ++ value_key ++ "\":");
519 try appendJsonString(allocator, out, item.value);
520 try out.append(allocator, '}');
521 }
522 try out.append(allocator, ']');
523}
524
525/// One contact as a JSON object, in a fixed key order.
526///
527/// Swift's `[String: Any]` has no order at all; fixing one here is what makes
528/// the bytes testable. The two `value_key` spellings are the part worth
529/// watching: phones are `number`, emails are `address`, and `formatContact` is
530/// the only authority for that.
531fn appendContact(
532 allocator: std.mem.Allocator,
533 out: *std.ArrayListUnmanaged(u8),
534 contact: Contact,
535) !void {
536 try out.appendSlice(allocator, "{\"id\":");
537 try appendJsonString(allocator, out, contact.id);
538 try out.appendSlice(allocator, ",\"givenName\":");
539 try appendJsonString(allocator, out, contact.given_name);
540 try out.appendSlice(allocator, ",\"familyName\":");
541 try appendJsonString(allocator, out, contact.family_name);
542 try out.appendSlice(allocator, ",\"displayName\":");
543 try appendJsonString(allocator, out, contact.display_name);
544 try out.appendSlice(allocator, ",\"phoneNumbers\":");
545 try appendLabeledArray(allocator, out, contact.phones, "number");
546 try out.appendSlice(allocator, ",\"emailAddresses\":");
547 try appendLabeledArray(allocator, out, contact.emails, "address");
548 try out.append(allocator, '}');
549}
550
551/// The `didSelectContact:` reply: a bare object.
552fn shapeContact(allocator: std.mem.Allocator, contact: Contact) ![]u8 {
553 var out: std.ArrayListUnmanaged(u8) = .empty;
554 errdefer out.deinit(allocator);
555 try appendContact(allocator, &out, contact);
556 return out.toOwnedSlice(allocator);
557}
558
559/// The `didSelectContacts:` reply: an array of objects.
560///
561/// An empty selection is `[]`, which **resolves**. `contacts.map` over an empty
562/// array is `[]` in Swift and `test-bridges.html` reads `.length` off it, so
563/// turning "nothing selected" into an error or a cancel would break a path the
564/// shim answers successfully.
565fn shapeContacts(allocator: std.mem.Allocator, contacts: []const Contact) ![]u8 {
566 var out: std.ArrayListUnmanaged(u8) = .empty;
567 errdefer out.deinit(allocator);
568
569 try out.append(allocator, '[');
570 for (contacts, 0..) |contact, i| {
571 if (i != 0) try out.append(allocator, ',');
572 try appendContact(allocator, &out, contact);
573 }
574 try out.append(allocator, ']');
575
576 return out.toOwnedSlice(allocator);
577}
578
579// =============================================================================
580// Objective-C: everything looked up once at dispatch, then a walk that cannot
581// look anything up.
582// =============================================================================
583
584/// The Info.plist key `packages/ios/src/index.ts:189` writes if and only if
585/// `config.enableContacts`.
586const key_contacts_usage = "NSContactsUsageDescription";
587
588/// `NSUTF8StringEncoding`. Used only to ask a string how long it really is, so
589/// a NUL-truncated read can be told from a short string.
590const ns_utf8_string_encoding: c_ulong = 4;
591
592/// `CNContactFormatterStyleFullName`. A header enum constant with `FullName = 0`
593/// and `PhoneticFullName = 1`, so this one is safe to spell out rather than
594/// `dlsym`.
595const cn_contact_formatter_style_full_name: c_long = 0;
596
597fn selector(name: [*:0]const u8) !Id {
598 if (!is_darwin) return error.UnsupportedPlatform;
599 return objc.sel_registerName(name) orelse error.SelectorNotFound;
600}
601
602/// The main bundle's Info.plist value for `key`, or null when it has none.
603///
604/// Errors rather than answering null when the runtime itself will not
605/// cooperate: "there is no NSBundle class" and "this app was not built with
606/// contacts enabled" are different facts, and collapsing them would blame the
607/// app's configuration for a broken process. Same shape as
608/// `bridge_mobile_location.zig`'s gate, which is private to that file.
609fn infoPlistValue(comptime key: [*:0]const u8) !Id {
610 if (!is_darwin) return error.UnsupportedPlatform;
611
612 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
613 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
614 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
615
616 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
617 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
618 return error.SelectorNotFound;
619 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
620
621 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
622 return error.SelectorNotFound;
623 return objc.msgSendId1(bundle, sel_lookup, ns_key);
624}
625
626/// Refuse when the app was not built with `enableContacts`.
627///
628/// Checked first, matching Swift's `if config.enableContacts` guarding the whole
629/// case. The key is evidence of that flag and nothing more — see the module
630/// comment: `CNContactPickerViewController` needs no Contacts authorization, so
631/// this is not the framework's precondition being enforced.
632fn requireContactsConfigured() !void {
633 if (!is_darwin) return error.UnsupportedPlatform;
634
635 if ((try infoPlistValue(key_contacts_usage)) == null) {
636 std.log.warn(
637 "pickContact refused: Info.plist has no {s}, so this app was not built with " ++
638 "contacts enabled",
639 .{key_contacts_usage},
640 );
641 return bridge_error.BridgeError.PermissionDenied;
642 }
643}
644
645/// Everything a delegate callback needs, resolved while a synchronous error can
646/// still reach the page.
647///
648/// The callbacks run long after the dispatch frame is gone, where a failed
649/// `sel_registerName` could only be logged. Resolving here turns that whole
650/// class of failure into an ordinary rejection.
651///
652/// Two *class* objects ride along for the same reason. `CNContactFormatter` and
653/// `CNLabeledValue` are used as receivers of class methods
654/// (`stringFromContact:style:`, `localizedStringForLabel:`), so a missing
655/// Contacts.framework has to be found here rather than inside the callback.
656/// They are guarded separately from `CNContactPickerViewController` because
657/// they come from a different framework.
658const Sels = struct {
659 // Contacts.framework classes, as receivers of class methods.
660 formatter_class: Id,
661 labeled_value_class: Id,
662 string_class: Id,
663
664 // NSArray
665 count: Id,
666 object_at: Id,
667
668 // CNContact
669 identifier: Id,
670 given_name: Id,
671 family_name: Id,
672 phone_numbers: Id,
673 email_addresses: Id,
674
675 // CNContactFormatter (class method)
676 string_from_contact: Id,
677
678 // CNLabeledValue. One class, not two: the Swift generics
679 // `CNLabeledValue<CNPhoneNumber>` and `CNLabeledValue<NSString>` are the
680 // same Objective-C class.
681 label: Id,
682 value: Id,
683 localized_string_for_label: Id,
684
685 // CNPhoneNumber
686 string_value: Id,
687
688 // NSString
689 empty_string: Id,
690 utf8: Id,
691 length_of_bytes: Id,
692
693 fn resolve() !Sels {
694 if (!is_darwin) return error.UnsupportedPlatform;
695
696 const formatter = objc.objc_getClass("CNContactFormatter") orelse {
697 std.log.err(
698 "pickContact refused: this process has no CNContactFormatter; " ++
699 "Contacts.framework is not linked",
700 .{},
701 );
702 return bridge_error.BridgeError.PlatformNotSupported;
703 };
704 const labeled = objc.objc_getClass("CNLabeledValue") orelse {
705 std.log.err(
706 "pickContact refused: this process has no CNLabeledValue; " ++
707 "Contacts.framework is not linked",
708 .{},
709 );
710 return bridge_error.BridgeError.PlatformNotSupported;
711 };
712 const string_class = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
713
714 return .{
715 .formatter_class = formatter,
716 .labeled_value_class = labeled,
717 .string_class = string_class,
718
719 .count = try selector("count"),
720 .object_at = try selector("objectAtIndex:"),
721
722 .identifier = try selector("identifier"),
723 .given_name = try selector("givenName"),
724 .family_name = try selector("familyName"),
725 .phone_numbers = try selector("phoneNumbers"),
726 .email_addresses = try selector("emailAddresses"),
727
728 .string_from_contact = try selector("stringFromContact:style:"),
729
730 .label = try selector("label"),
731 .value = try selector("value"),
732 .localized_string_for_label = try selector("localizedStringForLabel:"),
733
734 .string_value = try selector("stringValue"),
735
736 .empty_string = try selector("string"),
737 .utf8 = try selector("UTF8String"),
738 .length_of_bytes = try selector("lengthOfBytesUsingEncoding:"),
739 };
740 }
741};
742
743/// One `NSString` as bytes, or a refusal.
744///
745/// The truncation check is why this is not one line. `UTF8String` hands back a
746/// NUL-terminated buffer, so a value containing U+0000 — which
747/// `JSONSerialization` would have preserved on the Swift side — reads back as
748/// its prefix. `lengthOfBytesUsingEncoding:` counts the real encoded bytes, so a
749/// mismatch is exactly that case, and reporting a prefix as the whole name is
750/// the one thing worse than failing.
751///
752/// The returned slice borrows the string's internal buffer, valid for the
753/// current autorelease pool; every caller copies it into the reply before
754/// returning.
755fn readString(ns: Id, sels: Sels) ![]const u8 {
756 if (!is_darwin) return error.UnsupportedPlatform;
757 if (ns == null) return error.NilNativeString;
758
759 const Utf8Fn = *const fn (Id, Id) callconv(.c) ?[*:0]const u8;
760 const utf8: Utf8Fn = @ptrCast(&objc.objc_msgSend);
761 const cstr = utf8(ns, sels.utf8) orelse return error.NativeCallFailed;
762 const text = std.mem.span(cstr);
763
764 const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong;
765 const len_of: LenFn = @ptrCast(&objc.objc_msgSend);
766 if (len_of(ns, sels.length_of_bytes, ns_utf8_string_encoding) != text.len) {
767 return error.EmbeddedNulInNativeString;
768 }
769
770 return text;
771}
772
773/// `[[NSString string]]` — an empty, autoreleased `NSString`.
774///
775/// Needed because `CNLabeledValue.label` is nullable and Swift passes `""` in
776/// its place (`phone.label ?? ""`) before asking for the localized form. Passing
777/// nil instead would be a different call than the one being ported.
778fn emptyString(sels: Sels) !Id {
779 if (!is_darwin) return error.UnsupportedPlatform;
780 return objc.msgSendId(sels.string_class, sels.empty_string) orelse error.NativeCallFailed;
781}
782
783/// `+[CNLabeledValue localizedStringForLabel:]` on `labeled.label ?? ""`.
784fn readLocalizedLabel(labeled: Id, sels: Sels) ![]const u8 {
785 if (!is_darwin) return error.UnsupportedPlatform;
786
787 const raw = objc.msgSendId(labeled, sels.label) orelse try emptyString(sels);
788 const localized = objc.msgSendId1(sels.labeled_value_class, sels.localized_string_for_label, raw) orelse
789 return error.NativeCallFailed;
790 return readString(localized, sels);
791}
792
793/// Which kind of `CNLabeledValue` array is being walked.
794///
795/// The two differ in exactly one step: a phone's `value` is a `CNPhoneNumber`
796/// and needs `stringValue`, while an email's `value` already *is* an
797/// `NSString`. Sending `stringValue` to an `NSString` would be an unrecognised
798/// selector, which is a SIGABRT rather than an error to map — so this is an
799/// enum rather than an optional selector that could arrive null.
800const LabeledKind = enum { phone, email };
801
802/// Walk an `NSArray<CNLabeledValue *>` into `Labeled` entries.
803fn readLabeledValues(
804 allocator: std.mem.Allocator,
805 array: Id,
806 sels: Sels,
807 comptime kind: LabeledKind,
808) ![]Labeled {
809 if (!is_darwin) return error.UnsupportedPlatform;
810 // Both `phoneNumbers` and `emailAddresses` are declared non-null and answer
811 // an empty array for a contact with none. A nil here would mean the
812 // framework broke its own contract, and "this contact has no numbers" is a
813 // claim there would be no basis for.
814 if (array == null) return error.NativeCallFailed;
815
816 const CountFn = *const fn (Id, Id) callconv(.c) c_ulong;
817 const count_of: CountFn = @ptrCast(&objc.objc_msgSend);
818 const total = count_of(array, sels.count);
819
820 var out: std.ArrayListUnmanaged(Labeled) = .empty;
821 errdefer out.deinit(allocator);
822
823 var i: c_ulong = 0;
824 while (i < total) : (i += 1) {
825 const entry = objc.msgSendId1(array, sels.object_at, i) orelse return error.NativeCallFailed;
826
827 const label = try readLocalizedLabel(entry, sels);
828
829 const raw_value = objc.msgSendId(entry, sels.value) orelse return error.NativeCallFailed;
830 const string_value = switch (kind) {
831 .phone => objc.msgSendId(raw_value, sels.string_value) orelse
832 return error.NativeCallFailed,
833 .email => raw_value,
834 };
835
836 try out.append(allocator, .{ .label = label, .value = try readString(string_value, sels) });
837 }
838
839 return out.toOwnedSlice(allocator);
840}
841
842/// Read one `CNContact` the way `formatContact` reads it.
843///
844/// `displayName` is the one field where nil is expected and is *not* a failure:
845/// `CNContactFormatter.string(from:style:)` returns `String?` and Swift coalesces
846/// it with `?? ""`. Every other field is declared non-null — `identifier`,
847/// `givenName` and `familyName` answer `@""` for an unset name — so nil there is
848/// a broken read and is refused rather than papered over with an empty string.
849fn readContact(allocator: std.mem.Allocator, contact: Id, sels: Sels) !Contact {
850 if (!is_darwin) return error.UnsupportedPlatform;
851 if (contact == null) return error.NativeCallFailed;
852
853 const FormatFn = *const fn (Id, Id, Id, c_long) callconv(.c) Id;
854 const format: FormatFn = @ptrCast(&objc.objc_msgSend);
855 const formatted = format(
856 sels.formatter_class,
857 sels.string_from_contact,
858 contact,
859 cn_contact_formatter_style_full_name,
860 );
861
862 const phones = try readLabeledValues(
863 allocator,
864 objc.msgSendId(contact, sels.phone_numbers),
865 sels,
866 .phone,
867 );
868 errdefer allocator.free(phones);
869
870 const emails = try readLabeledValues(
871 allocator,
872 objc.msgSendId(contact, sels.email_addresses),
873 sels,
874 .email,
875 );
876 errdefer allocator.free(emails);
877
878 return .{
879 .id = try readString(objc.msgSendId(contact, sels.identifier), sels),
880 .given_name = try readString(objc.msgSendId(contact, sels.given_name), sels),
881 .family_name = try readString(objc.msgSendId(contact, sels.family_name), sels),
882 // Swift's `?? ""`, and only here.
883 .display_name = if (formatted == null) "" else try readString(formatted, sels),
884 .phones = phones,
885 .emails = emails,
886 };
887}
888
889/// Release the two slices `readContact` allocated. The strings inside are
890/// borrowed from Objective-C and are not ours to free.
891fn freeContact(allocator: std.mem.Allocator, contact: Contact) void {
892 allocator.free(contact.phones);
893 allocator.free(contact.emails);
894}
895
896// =============================================================================
897// The delegate class, built at runtime.
898// =============================================================================
899
900/// Deliberately distinct from every other class this process registers. The
901/// factory looks a name up before allocating, so two files sharing a name would
902/// mean whichever registered second silently adopts the other's IMPs.
903const delegate_class_name = "CraftIOSContactPickerDelegate";
904
905/// The delegate instance, held for the life of the process.
906///
907/// `CNContactPickerViewController` holds its delegate **weakly**;
908/// `ios_delegate.instantiate` hands back a +1 object and does not keep it. This
909/// var is the strong reference, and dropping it would be a use-after-free the
910/// first time the user taps Cancel — not a leak.
911var delegate_instance: Id = null;
912
913/// Every method the delegate class carries, always all of them.
914///
915/// All three of Swift's `CNContactPickerDelegate` methods are here.
916/// `contactPicker:didSelectContacts:` is the plural selector Swift's
917/// `didSelect contacts: [CNContact]` compiles to and is the entire
918/// `multiple:true` reply path — the runtime dispatches delegate methods through
919/// `respondsToSelector:`, so omitting it would be a silent no-op and a stranded
920/// promise. `presentationControllerDidDismiss:` is the fourth and is not
921/// Swift's: it belongs to `UIAdaptivePresentationControllerDelegate` and is the
922/// swipe-down belt `attachDismissObserver` installs. Neither protocol is
923/// declared on the class, because `respondsToSelector:` is what UIKit actually
924/// asks — the same call `bridge_mobile_imagepicker.zig` records.
925///
926/// One fixed set rather than a per-call one: `defineClass` is idempotent by
927/// name, so varying the methods would mean inventing a second class.
928fn delegateMethods() [4]ios_delegate.Method {
929 return .{
930 // - (void)contactPicker:(CNContactPickerViewController *)picker
931 // didSelectContact:(CNContact *)contact
932 .{
933 .selector = "contactPicker:didSelectContact:",
934 .imp = @ptrCast(&didSelectContact),
935 .types = ios_delegate.enc.void_two_objects,
936 },
937 // - (void)contactPicker:(CNContactPickerViewController *)picker
938 // didSelectContacts:(NSArray<CNContact *> *)contacts
939 .{
940 .selector = "contactPicker:didSelectContacts:",
941 .imp = @ptrCast(&didSelectContacts),
942 .types = ios_delegate.enc.void_two_objects,
943 },
944 // - (void)contactPickerDidCancel:(CNContactPickerViewController *)picker
945 .{
946 .selector = "contactPickerDidCancel:",
947 .imp = @ptrCast(&didCancel),
948 .types = ios_delegate.enc.void_one_object,
949 },
950 // - (void)presentationControllerDidDismiss:(UIPresentationController *)pc
951 // The swipe-down belt. iOS 13+; on anything older it is never sent.
952 .{
953 .selector = "presentationControllerDidDismiss:",
954 .imp = @ptrCast(&didDismissInteractively),
955 .types = ios_delegate.enc.void_one_object,
956 },
957 };
958}
959
960/// Register the class once and keep one instance alive.
961fn ensureDelegate() !Id {
962 if (!is_darwin) return error.UnsupportedPlatform;
963 if (delegate_instance) |existing| return existing;
964
965 const methods = delegateMethods();
966 const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &methods);
967
968 const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed;
969 delegate_instance = instance;
970 return instance;
971}
972
973/// The three selectors the swipe-down belt needs, resolved at dispatch.
974const DismissSels = struct {
975 /// `-[UIViewController presentationController]`.
976 presentation: Id,
977 /// `-[UIPresentationController delegate]`, read so an existing delegate is
978 /// never displaced.
979 delegate: Id,
980 /// `-[UIPresentationController setDelegate:]` — the same `setDelegate:` the
981 /// picker's own delegate is installed with.
982 set_delegate: Id,
983};
984
985/// Point the picker's presentation controller at this module's delegate, so an
986/// interactive dismissal reaches `presentationControllerDidDismiss:`. Returns
987/// whether it was attached.
988///
989/// `CNContactPickerViewController` presents as a sheet on iOS 13+, and a
990/// swipe-down is widely reported not to send `contactPickerDidCancel:`. That
991/// report is verified on no device in this repo, so this is a belt and not a
992/// fix: it costs two messages, and if the report is right it is the only thing
993/// that ever frees `pending` after a swipe. It matters more here than in Swift,
994/// because this module *refuses* later calls while one is pending — a stranded
995/// ticket disables `pickContact` for the life of the process, where Swift
996/// merely strands the one promise.
997///
998/// A presentation controller that already carries a delegate is left alone and
999/// reported as not attached: displacing one would change how the picker manages
1000/// its own adaptive presentation, which is a new bug in exchange for a belt.
1001///
1002/// Idempotent against the real cancel. UIKit sends
1003/// `presentationControllerDidDismiss:` only for a user-driven dismissal, never
1004/// for a programmatic one, so it cannot follow a pick or a Cancel that already
1005/// answered — and if it did anyway, `takePending` has already emptied the slot
1006/// and `ios_async`'s generation counter would drop the second delivery.
1007fn attachDismissObserver(picker: Id, delegate: Id, sels: DismissSels) bool {
1008 if (!is_darwin) return false;
1009
1010 const presentation = objc.msgSendId(picker, sels.presentation) orelse return false;
1011 if (objc.msgSendId(presentation, sels.delegate) != null) return false;
1012
1013 objc.msgSendVoid1(presentation, sels.set_delegate, delegate);
1014 return true;
1015}
1016
1017// =============================================================================
1018// One ticket, one picker, one mutex.
1019//
1020// A picker is modal and serves exactly one call, so unlike the location
1021// one-shot there is never a reason to displace: while `pending` is set the
1022// picker is on screen and the user is looking at it. The mutex is held even
1023// though UIKit delivers these callbacks on the main thread, because "it should
1024// always be the main thread" is not a guard.
1025// =============================================================================
1026
1027const Pending = struct {
1028 ticket: ios_async.Ticket,
1029 sels: Sels,
1030};
1031
1032var pending: ?Pending = null;
1033var pending_mutex: compat_mutex.Mutex = .{};
1034
1035/// Record the call the delegate will answer. False means one is already
1036/// recorded, and the caller must refuse rather than overwrite — an overwrite
1037/// would strand a promise that was about to be settled.
1038fn publishPending(call: Pending) bool {
1039 pending_mutex.lock();
1040 defer pending_mutex.unlock();
1041 if (pending != null) return false;
1042 pending = call;
1043 return true;
1044}
1045
1046/// Read and clear. Clearing is what makes a second delegate call — a duplicate
1047/// fire, a cancel that races a selection — a no-op rather than a second reply
1048/// to a promise that has already settled.
1049fn takePending() ?Pending {
1050 pending_mutex.lock();
1051 defer pending_mutex.unlock();
1052 const call = pending;
1053 pending = null;
1054 return call;
1055}
1056
1057// =============================================================================
1058// The delegate methods.
1059//
1060// None replies directly: `evaluateJavaScript` is main-thread-only *and* the
1061// `request_context` that names the waiting call is long gone, so the finished
1062// JSON goes to `ios_async.deliverJson` (or `deliverError`), which hops to the
1063// main queue and answers under the id captured back at dispatch.
1064//
1065// Plain `fn`, never `export`: `@ptrCast(&f)` works either way and an exported
1066// name could collide with a desktop module in the same host-test binary.
1067//
1068// No `dismissViewControllerAnimated:` in any of them. See the module comment —
1069// `CNContactPickerViewController` is a remote view controller that takes itself
1070// down, Swift dismisses it nowhere, and a second dismissal would target
1071// whatever else is modal. `didDismissInteractively` observes a dismissal the
1072// user performed; it never asks for one.
1073// =============================================================================
1074
1075fn didSelectContact(_: Id, _: Id, _: Id, contact: Id) callconv(.c) void {
1076 if (!is_darwin) return;
1077
1078 const call = takePending() orelse {
1079 std.log.warn(
1080 "pickContact: a contact was selected with no request waiting; ignored rather than " ++
1081 "answered to whoever holds the slot next",
1082 .{},
1083 );
1084 return;
1085 };
1086
1087 const allocator = std.heap.c_allocator;
1088
1089 const one = readContact(allocator, contact, call.sels) catch |err| {
1090 std.log.err(
1091 "pickContact: could not read the selected CNContact ({}); rejecting rather than " ++
1092 "replying with a contact that was not read",
1093 .{err},
1094 );
1095 ios_async.deliverError(call.ticket);
1096 return;
1097 };
1098 defer freeContact(allocator, one);
1099
1100 const json = shapeContact(allocator, one) catch |err| {
1101 std.log.err("pickContact: could not shape the contact reply ({}); rejecting", .{err});
1102 ios_async.deliverError(call.ticket);
1103 return;
1104 };
1105 defer allocator.free(json);
1106
1107 ios_async.deliverJson(call.ticket, json);
1108}
1109
1110fn didSelectContacts(_: Id, _: Id, _: Id, contacts: Id) callconv(.c) void {
1111 if (!is_darwin) return;
1112
1113 const call = takePending() orelse {
1114 std.log.warn(
1115 "pickContact: contacts were selected with no request waiting; ignored rather than " ++
1116 "answered to whoever holds the slot next",
1117 .{},
1118 );
1119 return;
1120 };
1121
1122 const allocator = std.heap.c_allocator;
1123
1124 const list = readContacts(allocator, contacts, call.sels) catch |err| {
1125 std.log.err(
1126 "pickContact: could not read the selected CNContacts ({}); rejecting rather than " ++
1127 "replying with a selection that was not read",
1128 .{err},
1129 );
1130 ios_async.deliverError(call.ticket);
1131 return;
1132 };
1133 defer {
1134 for (list) |one| freeContact(allocator, one);
1135 allocator.free(list);
1136 }
1137
1138 const json = shapeContacts(allocator, list) catch |err| {
1139 std.log.err("pickContact: could not shape the contacts reply ({}); rejecting", .{err});
1140 ios_async.deliverError(call.ticket);
1141 return;
1142 };
1143 defer allocator.free(json);
1144
1145 // An empty selection is `[]` and resolves — see `shapeContacts`.
1146 ios_async.deliverJson(call.ticket, json);
1147}
1148
1149/// Walk `NSArray<CNContact *>`, reading each contact.
1150///
1151/// A nil array is refused rather than answered `[]`: the delegate parameter is
1152/// `[CNContact]`, non-optional, so nil would mean the framework broke its own
1153/// contract and "the user selected nothing" is a claim with no basis.
1154fn readContacts(allocator: std.mem.Allocator, contacts: Id, sels: Sels) ![]Contact {
1155 if (!is_darwin) return error.UnsupportedPlatform;
1156 if (contacts == null) return error.NativeCallFailed;
1157
1158 const CountFn = *const fn (Id, Id) callconv(.c) c_ulong;
1159 const count_of: CountFn = @ptrCast(&objc.objc_msgSend);
1160 const total = count_of(contacts, sels.count);
1161
1162 var out: std.ArrayListUnmanaged(Contact) = .empty;
1163 errdefer {
1164 for (out.items) |one| freeContact(allocator, one);
1165 out.deinit(allocator);
1166 }
1167
1168 var i: c_ulong = 0;
1169 while (i < total) : (i += 1) {
1170 const contact = objc.msgSendId1(contacts, sels.object_at, i) orelse
1171 return error.NativeCallFailed;
1172 try out.append(allocator, try readContact(allocator, contact, sels));
1173 }
1174
1175 return out.toOwnedSlice(allocator);
1176}
1177
1178/// The user tapped Cancel.
1179///
1180/// A **rejection**, matching `rejectCallback(pendingCallbackId, error: "Cancelled")`
1181/// — never a resolve. Replying `{"cancelled":true}` here would be a success for
1182/// a pick that did not happen, and the page's `catch` (which is where
1183/// `test-bridges.html` prints the outcome) would never run.
1184///
1185/// The code and message the page sees are `NATIVE_CALL_FAILED` /
1186/// `"Native API call failed"` rather than Swift's `CRAFT_ERROR` / `"Cancelled"`.
1187/// That is `ios_async.deliverError`'s fixed wording and it is genuinely wrong
1188/// about the cause; the module comment records what it would take to fix, and
1189/// why inventing a second reply path here would be worse.
1190fn didCancel(_: Id, _: Id, _: Id) callconv(.c) void {
1191 if (!is_darwin) return;
1192
1193 const call = takePending() orelse {
1194 std.log.info("pickContact: a cancel arrived with no request waiting; ignored", .{});
1195 return;
1196 };
1197
1198 std.log.info(
1199 "pickContact: the user cancelled; rejecting the call (as CRAFT_ERROR/\"Cancelled\" in " ++
1200 "Swift, as NATIVE_CALL_FAILED here)",
1201 .{},
1202 );
1203 // A Cancel is not a native failure: the call worked and the answer is
1204 // "no". Reporting NativeCallFailed sends whoever reads the error looking
1205 // for a bug that is not there.
1206 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Cancelled);
1207}
1208
1209/// The user swiped the picker away.
1210///
1211/// `-presentationControllerDidDismiss:`, and the only thing that answers a
1212/// dismissal `contactPickerDidCancel:` is reported not to follow. A
1213/// **rejection**, for the same reason a Cancel is one: nothing was picked, so
1214/// resolving anything here would be a success for a pick that did not happen.
1215///
1216/// Reached with the slot already empty whenever a real callback got there first
1217/// — a Cancel that did send, or a pick — in which case this is a no-op rather
1218/// than a second answer.
1219///
1220/// Same `NATIVE_CALL_FAILED` / `"Native API call failed"` wording as `didCancel`
1221/// and wrong about the cause in the same way; see the module comment for where
1222/// that is fixed.
1223fn didDismissInteractively(_: Id, _: Id, _: Id) callconv(.c) void {
1224 if (!is_darwin) return;
1225
1226 const call = takePending() orelse {
1227 std.log.info(
1228 "pickContact: the picker was dismissed with no request waiting; ignored " ++
1229 "(the cancel or the pick already answered)",
1230 .{},
1231 );
1232 return;
1233 };
1234
1235 std.log.warn(
1236 "pickContact: the picker was dismissed interactively without sending " ++
1237 "contactPickerDidCancel:; rejecting the call rather than leaving it unanswered " ++
1238 "on a promise that has no timeout",
1239 .{},
1240 );
1241 ios_async.deliverError(call.ticket);
1242}
1243
1244// =============================================================================
1245// Tests — host-only.
1246//
1247// Everything that decides page-visible bytes is pure and pinned here: routing
1248// in both directions, the `as? Bool ?? false` coercion, the six keys and their
1249// order, the two different value keys for phones and emails, the empty-array
1250// resolve, escaping, and the concurrency rule.
1251//
1252// Nothing here constructs a `CNContactPickerViewController` or presents
1253// anything. On a macOS host that class does not exist at all (macOS has
1254// `CNContactPicker`), and presenting from a test process is not something a
1255// suite should be able to do by accident.
1256// =============================================================================
1257
1258const testing = std.testing;
1259
1260/// The pending slot is module state, and a test that leaves it set would make
1261/// the next one refuse. Every test that touches it clears it through here.
1262fn resetPendingForTesting() void {
1263 pending_mutex.lock();
1264 defer pending_mutex.unlock();
1265 pending = null;
1266}
1267
1268fn fakeTicket(index: u5, generation: u32) ios_async.Ticket {
1269 return .{ .index = index, .generation = generation };
1270}
1271
1272fn fakeSels() Sels {
1273 // Every field is a plain `Id`; nothing below dereferences them, and the
1274 // concurrency rule under test does not care what they point at.
1275 return std.mem.zeroes(Sels);
1276}
1277
1278test "the declared action is the one the handler serves" {
1279 try testing.expectEqual(@as(usize, 1), capability_actions.len);
1280 try testing.expectEqualStrings(A.pick_contact, capability_actions[0].name);
1281
1282 for (capability_actions) |decl| {
1283 // A `.result` whose handler never replies parks the caller on a promise
1284 // with no timeout; a `.none` that is awaited resolves immediately and
1285 // means nothing. Swift resolves or rejects every path, so `.result`.
1286 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1287 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1288 // `.live` with a reason would be a contradiction the manifest shows apps.
1289 try testing.expect(decl.reason == null);
1290 }
1291}
1292
1293test "the action name matches the Swift case label exactly" {
1294 // The conformance ratchet compares this against the `case "…":` labels in
1295 // `CraftApp.swift` in both directions; a prettier spelling would read as Zig
1296 // serving an action the spec does not have.
1297 try testing.expectEqualStrings("pickContact", A.pick_contact);
1298}
1299
1300test "every declared action is one the dispatcher routes" {
1301 for (capability_actions) |decl| {
1302 if (routeFor(decl.name) == null) {
1303 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1304 return error.DeclaredActionDoesNotRoute;
1305 }
1306 }
1307}
1308
1309test "every route the dispatcher has is a declared action" {
1310 // The other direction: a route with a handler and no declaration is an
1311 // action the page can call and the manifest denies exists. Each declaration
1312 // must claim a *distinct* route — counting alone would let two rows share
1313 // one route while another went undeclared.
1314 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
1315 for (capability_actions) |decl| {
1316 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1317 const slot = @backingInt(route);
1318 if (claimed[slot]) {
1319 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1320 return error.TwoDeclarationsShareARoute;
1321 }
1322 claimed[slot] = true;
1323 }
1324 for (claimed, 0..) |taken, slot| {
1325 if (!taken) {
1326 std.debug.print(
1327 "route {s} has a handler but no capability_actions row\n",
1328 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
1329 );
1330 return error.RouteNotDeclared;
1331 }
1332 }
1333}
1334
1335test "an action this module does not serve is refused as UnknownAction" {
1336 // Not any other error: `ios_dispatch.route` reads UnknownAction as "not
1337 // mine, ask the next module" and anything else as a final answer. Getting
1338 // this wrong would make this file swallow another module's action — or the
1339 // shim's.
1340 var bridge = ContactPickerBridge.init(testing.allocator);
1341 defer bridge.deinit();
1342
1343 for ([_][]const u8{
1344 "pickImage",
1345 "pickDocument",
1346 "getContacts",
1347 "addContact",
1348 // Casing is how a real typo arrives, and a miss does not fail loudly:
1349 // the action would quietly fall through to the Swift shim.
1350 "pickcontact",
1351 "PickContact",
1352 "pickContacts",
1353 "",
1354 }) |action| {
1355 try testing.expectError(
1356 bridge_error.BridgeError.UnknownAction,
1357 bridge.handleMessage(action, "{}"),
1358 );
1359 try testing.expect(routeFor(action) == null);
1360 }
1361}
1362
1363test "pickContact routes to its own handler" {
1364 try testing.expectEqual(Route.pick_contact, routeFor("pickContact").?);
1365}
1366
1367// ---------------------------------------------------------------------------
1368// Payload parsing
1369// ---------------------------------------------------------------------------
1370
1371test "an absent multiple is false, exactly as Swift's ?? false is" {
1372 // `ios_dispatch.payloadOf` hands an absent `d` through as `{}`, which is the
1373 // first case here. The page always sends the field today, but a body without
1374 // it must not become a multi-select picker.
1375 try testing.expect(!try parseMultiple(testing.allocator, "{}"));
1376 try testing.expect(!try parseMultiple(testing.allocator, "{\"other\":1}"));
1377}
1378
1379test "a boolean multiple is carried through, both ways" {
1380 try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":true}"));
1381 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":false}"));
1382}
1383
1384test "the numbers NSNumber-as-Bool accepts are the only numbers accepted" {
1385 // `options.multiple || false` is a fallback, not a coercion, so
1386 // `pickContact({multiple: 1})` really does put `1` on the wire. Swift's
1387 // `as? Bool` succeeds for 0 and 1 and fails for everything else.
1388 try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":1}"));
1389 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0}"));
1390 try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":1.0}"));
1391 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0.0}"));
1392
1393 // 2 is truthy in JavaScript and still opens a single-selection picker,
1394 // because `as? Bool` returns nil for it. This is the case a "truthy means
1395 // multiple" reading would get wrong.
1396 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":2}"));
1397 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":-1}"));
1398 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0.5}"));
1399}
1400
1401test "a non-numeric truthy multiple is false, not multi" {
1402 // `pickContact({multiple: 'yes'})` puts the string on the wire and Swift
1403 // reads it as false.
1404 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":\"yes\"}"));
1405 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":\"true\"}"));
1406 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":[]}"));
1407 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":{}}"));
1408 // `JSON.stringify` keeps an explicit null where it drops an undefined.
1409 try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":null}"));
1410}
1411
1412test "a body that cannot be read is an error, not a defaulted false" {
1413 // Acting on a default for a payload that failed to parse is how a field the
1414 // page sent goes missing with success reported anyway.
1415 try testing.expectError(
1416 bridge_error.BridgeError.InvalidJSON,
1417 parseMultiple(testing.allocator, "{\"multiple\":"),
1418 );
1419 try testing.expectError(
1420 bridge_error.BridgeError.InvalidJSON,
1421 parseMultiple(testing.allocator, "[1,2]"),
1422 );
1423 try testing.expectError(
1424 bridge_error.BridgeError.InvalidJSON,
1425 parseMultiple(testing.allocator, "\"multiple\""),
1426 );
1427}
1428
1429// ---------------------------------------------------------------------------
1430// Reply shaping
1431// ---------------------------------------------------------------------------
1432
1433test "one contact is the six-key object formatContact builds, in a fixed order" {
1434 const allocator = testing.allocator;
1435 const json = try shapeContact(allocator, .{
1436 .id = "AB12-CD34",
1437 .given_name = "Ada",
1438 .family_name = "Lovelace",
1439 .display_name = "Ada Lovelace",
1440 .phones = &.{.{ .label = "mobile", .value = "+1 555 0100" }},
1441 .emails = &.{.{ .label = "work", .value = "ada@example.com" }},
1442 });
1443 defer allocator.free(json);
1444
1445 try testing.expectEqualStrings(
1446 "{\"id\":\"AB12-CD34\",\"givenName\":\"Ada\",\"familyName\":\"Lovelace\"," ++
1447 "\"displayName\":\"Ada Lovelace\"," ++
1448 "\"phoneNumbers\":[{\"label\":\"mobile\",\"number\":\"+1 555 0100\"}]," ++
1449 "\"emailAddresses\":[{\"label\":\"work\",\"address\":\"ada@example.com\"}]}",
1450 json,
1451 );
1452}
1453
1454test "phones say number and emails say address" {
1455 // The single easiest thing to get backwards, and it is silent: both are
1456 // `{label, <string>}` and a swapped key still parses. `formatContact` is the
1457 // only authority, and `craft.d.ts` — which declares both as `string[]` — is
1458 // wrong for this action and must not be followed.
1459 const allocator = testing.allocator;
1460 const json = try shapeContact(allocator, .{
1461 .id = "1",
1462 .given_name = "",
1463 .family_name = "",
1464 .display_name = "",
1465 .phones = &.{.{ .label = "home", .value = "555" }},
1466 .emails = &.{.{ .label = "home", .value = "a@b.c" }},
1467 });
1468 defer allocator.free(json);
1469
1470 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
1471 defer parsed.deinit();
1472
1473 const phone = parsed.value.object.get("phoneNumbers").?.array.items[0].object;
1474 try testing.expectEqualStrings("555", phone.get("number").?.string);
1475 try testing.expect(phone.get("address") == null);
1476
1477 const email = parsed.value.object.get("emailAddresses").?.array.items[0].object;
1478 try testing.expectEqualStrings("a@b.c", email.get("address").?.string);
1479 try testing.expect(email.get("number") == null);
1480}
1481
1482test "a contact with no numbers and no addresses keeps both keys as empty arrays" {
1483 // `formatContact` always assigns both keys, so a page reading
1484 // `contact.phoneNumbers.length` must not meet `undefined`.
1485 const allocator = testing.allocator;
1486 const json = try shapeContact(allocator, .{
1487 .id = "1",
1488 .given_name = "Solo",
1489 .family_name = "",
1490 .display_name = "Solo",
1491 .phones = &.{},
1492 .emails = &.{},
1493 });
1494 defer allocator.free(json);
1495
1496 try testing.expect(std.mem.indexOf(u8, json, "\"phoneNumbers\":[]") != null);
1497 try testing.expect(std.mem.indexOf(u8, json, "\"emailAddresses\":[]") != null);
1498}
1499
1500test "a name that needs escaping stays valid JSON" {
1501 // A contact name is user data. Before escaping, a single `"` in it would
1502 // close the string literal inside the `evaluateJavaScript:` source and turn
1503 // the reply into a syntax error in the page rather than a wrong field.
1504 const allocator = testing.allocator;
1505 const json = try shapeContact(allocator, .{
1506 .id = "x\\y",
1507 .given_name = "Ann \"Annie\"",
1508 .family_name = "O'Neil\nJr",
1509 .display_name = "Ann\t\"Annie\" O'Neil",
1510 .phones = &.{.{ .label = "we\"ird", .value = "+1\\555" }},
1511 .emails = &.{.{ .label = "a\nb", .value = "q\"@example.com" }},
1512 });
1513 defer allocator.free(json);
1514
1515 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
1516 defer parsed.deinit();
1517
1518 const obj = parsed.value.object;
1519 try testing.expectEqualStrings("x\\y", obj.get("id").?.string);
1520 try testing.expectEqualStrings("Ann \"Annie\"", obj.get("givenName").?.string);
1521 try testing.expectEqualStrings("O'Neil\nJr", obj.get("familyName").?.string);
1522 try testing.expectEqualStrings(
1523 "we\"ird",
1524 obj.get("phoneNumbers").?.array.items[0].object.get("label").?.string,
1525 );
1526 try testing.expectEqualStrings(
1527 "q\"@example.com",
1528 obj.get("emailAddresses").?.array.items[0].object.get("address").?.string,
1529 );
1530}
1531
1532test "the multiple reply is an array, not an object" {
1533 // `test-bridges.html:1030` reads `contacts?.length`, so an object here would
1534 // print `undefined contacts` for a selection that worked.
1535 const allocator = testing.allocator;
1536 const json = try shapeContacts(allocator, &.{
1537 .{
1538 .id = "1",
1539 .given_name = "A",
1540 .family_name = "One",
1541 .display_name = "A One",
1542 .phones = &.{},
1543 .emails = &.{},
1544 },
1545 .{
1546 .id = "2",
1547 .given_name = "B",
1548 .family_name = "Two",
1549 .display_name = "B Two",
1550 .phones = &.{.{ .label = "mobile", .value = "555" }},
1551 .emails = &.{},
1552 },
1553 });
1554 defer allocator.free(json);
1555
1556 try testing.expect(json[0] == '[');
1557
1558 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
1559 defer parsed.deinit();
1560
1561 try testing.expectEqual(@as(usize, 2), parsed.value.array.items.len);
1562 try testing.expectEqualStrings("1", parsed.value.array.items[0].object.get("id").?.string);
1563 try testing.expectEqualStrings(
1564 "555",
1565 parsed.value.array.items[1].object.get("phoneNumbers").?.array.items[0].object.get("number").?.string,
1566 );
1567}
1568
1569test "an empty multi-selection is an empty array that resolves" {
1570 // `contacts.map` over `[]` is `[]` and Swift resolves it. Turning this into
1571 // an error, or into a cancel, would fail a call the shim answers. `[]` is
1572 // also truthy, so `craft-bridge.js`'s `payload || {}` leaves it intact.
1573 const allocator = testing.allocator;
1574 const json = try shapeContacts(allocator, &.{});
1575 defer allocator.free(json);
1576
1577 try testing.expectEqualStrings("[]", json);
1578
1579 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
1580 defer parsed.deinit();
1581 try testing.expectEqual(@as(usize, 0), parsed.value.array.items.len);
1582}
1583
1584test "the single reply is an object rather than an array of one" {
1585 // The two `didSelect` overloads resolve genuinely different shapes, and a
1586 // page doing `contact.givenName` after a single pick would read `undefined`
1587 // off a one-element array.
1588 const allocator = testing.allocator;
1589 const one = Contact{
1590 .id = "1",
1591 .given_name = "A",
1592 .family_name = "One",
1593 .display_name = "A One",
1594 .phones = &.{},
1595 .emails = &.{},
1596 };
1597
1598 const single = try shapeContact(allocator, one);
1599 defer allocator.free(single);
1600 const many = try shapeContacts(allocator, &.{one});
1601 defer allocator.free(many);
1602
1603 try testing.expect(single[0] == '{');
1604 try testing.expect(many[0] == '[');
1605
1606 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, single, .{});
1607 defer parsed.deinit();
1608 try testing.expectEqualStrings("A", parsed.value.object.get("givenName").?.string);
1609}
1610
1611test "a cancel is a rejection, and these are the exact bytes it puts on the wire" {
1612 // `didCancel` calls `ios_async.deliverError`, which lands in
1613 // `deliverOnMain` -> `sendErrorToJS(action, NativeCallFailed)`. This builds
1614 // the same envelope that path builds, so the page-visible result of a
1615 // cancel is pinned rather than described.
1616 //
1617 // Two things are being asserted at once. First, `"error":true` — it goes
1618 // out through `__craftBridgeError`, which *rejects*. A
1619 // `{"cancelled":true}` resolve (the shape the image and document pickers
1620 // use) would be a fabricated success for a pick that did not happen, and
1621 // the page's `catch` — where `test-bridges.html:1022` prints the outcome —
1622 // would never run.
1623 //
1624 // Second, the divergence, stated in bytes: Swift sends
1625 // `code:"CRAFT_ERROR"`, `message:"Cancelled"`. These are what the page gets
1626 // instead, and they are wrong about the cause. See the module comment for
1627 // why the fix belongs in `ios_async.zig` rather than in a second reply path
1628 // built here.
1629 const allocator = testing.allocator;
1630 var ctx = bridge_error.ErrorContext.init(
1631 bridge_error.BridgeError.NativeCallFailed,
1632 A.pick_contact,
1633 bridge_error.errorMessage(bridge_error.BridgeError.NativeCallFailed),
1634 );
1635 ctx.request_id = 11;
1636
1637 const json = try ctx.toJSON(allocator);
1638 defer allocator.free(json);
1639
1640 try testing.expectEqualStrings(
1641 "{\"error\":true,\"code\":\"NATIVE_CALL_FAILED\",\"action\":\"pickContact\"," ++
1642 "\"message\":\"Native API call failed\",\"id\":11}",
1643 json,
1644 );
1645
1646 // And it is not any of the resolve shapes: a rejection carries no contact.
1647 try testing.expect(std.mem.indexOf(u8, json, "givenName") == null);
1648}
1649
1650// ---------------------------------------------------------------------------
1651// Concurrency
1652// ---------------------------------------------------------------------------
1653
1654test "a second request while one is presented is refused, and the first is left alone" {
1655 resetPendingForTesting();
1656 defer resetPendingForTesting();
1657
1658 const first = fakeTicket(3, 7);
1659 try testing.expect(publishPending(.{ .ticket = first, .sels = fakeSels() }));
1660
1661 // The refusal. Swift overwrites `pendingCallbackId` here and the first
1662 // promise never settles; the picker on screen is about to answer, so the
1663 // newcomer is the one that has to be told no.
1664 try testing.expect(!publishPending(.{ .ticket = fakeTicket(4, 9), .sels = fakeSels() }));
1665
1666 // And the slot still holds the *first* ticket, not the newcomer's — a
1667 // refusal that quietly replaced would look identical from the return value.
1668 pending_mutex.lock();
1669 defer pending_mutex.unlock();
1670 try testing.expectEqual(first.index, pending.?.ticket.index);
1671 try testing.expectEqual(first.generation, pending.?.ticket.generation);
1672}
1673
1674test "the slot is handed over once, so a double-firing delegate cannot reply twice" {
1675 resetPendingForTesting();
1676 defer resetPendingForTesting();
1677
1678 const ticket = fakeTicket(1, 2);
1679 try testing.expect(publishPending(.{ .ticket = ticket, .sels = fakeSels() }));
1680
1681 const taken = takePending() orelse return error.PendingWentMissing;
1682 try testing.expectEqual(ticket.index, taken.ticket.index);
1683
1684 // A cancel racing a selection, or a duplicate fire. `ios_async` would ignore
1685 // the stale generation anyway, but the slot has to be empty here so the
1686 // callback never even reaches for a ticket that is already spent.
1687 try testing.expect(takePending() == null);
1688}
1689
1690test "the picker is reusable after it answers" {
1691 // The refusal above must not be permanent. Once a delegate callback has
1692 // taken the slot, the next pickContact publishes normally.
1693 resetPendingForTesting();
1694 defer resetPendingForTesting();
1695
1696 try testing.expect(publishPending(.{ .ticket = fakeTicket(0, 1), .sels = fakeSels() }));
1697 _ = takePending();
1698 try testing.expect(publishPending(.{ .ticket = fakeTicket(0, 3), .sels = fakeSels() }));
1699}
1700
1701test "a delegate callback with no request waiting takes nothing" {
1702 // The stray-callback path every IMP begins with. It has to be a
1703 // no-op rather than an answer, because the next thing to occupy the slot
1704 // would otherwise receive this picker's result.
1705 resetPendingForTesting();
1706 defer resetPendingForTesting();
1707
1708 try testing.expect(takePending() == null);
1709}
1710
1711test "the delegate class name is this module's alone" {
1712 // `ios_delegate.defineClass` looks a name up before allocating, so two files
1713 // registering one name means whichever ran second silently adopts the
1714 // other's IMPs — which read different objects and answer different tickets.
1715 try testing.expectEqualStrings("CraftIOSContactPickerDelegate", delegate_class_name);
1716}
1717
1718test "every delegate method is registered at the encoding its selector needs" {
1719 // A wrong encoding still registers and still dispatches, then reads
1720 // arguments from the wrong registers with no crash and no compile error. The
1721 // named constants are the only defence, so the *pairing* is pinned rather
1722 // than the constants alone: both `didSelect` selectors take the picker plus
1723 // one object, the cancel and the dismissal take one object each.
1724 //
1725 // The count is asserted too. `contactPicker:didSelectContacts:` is the whole
1726 // `multiple:true` reply path and `presentationControllerDidDismiss:` is the
1727 // only answer to a swipe-down; UIKit dispatches through
1728 // `respondsToSelector:`, so dropping either is a silent no-op and a promise
1729 // that never settles.
1730 const methods = delegateMethods();
1731 try testing.expectEqual(@as(usize, 4), methods.len);
1732
1733 const want = [_]struct { selector: []const u8, types: []const u8 }{
1734 .{ .selector = "contactPicker:didSelectContact:", .types = "v@:@@" },
1735 .{ .selector = "contactPicker:didSelectContacts:", .types = "v@:@@" },
1736 .{ .selector = "contactPickerDidCancel:", .types = "v@:@" },
1737 .{ .selector = "presentationControllerDidDismiss:", .types = "v@:@" },
1738 };
1739
1740 for (methods, 0..) |method, i| {
1741 try testing.expectEqualStrings(want[i].selector, method.selector);
1742 try testing.expectEqualStrings(want[i].types, method.types);
1743 }
1744
1745 // And the encodings really are the named ones, not two strings that happen
1746 // to match today.
1747 try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[0].types);
1748 try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[1].types);
1749 try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[2].types);
1750 try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[3].types);
1751}
1752
1753test "no two delegate methods share a selector" {
1754 // `class_addMethod` for a selector the class already has is a failure the
1755 // factory turns into `MethodNotAdded`, so a duplicate would take the whole
1756 // registration down — and a typo that made two rows equal would otherwise
1757 // read as an extra method rather than a missing one.
1758 const methods = delegateMethods();
1759 for (methods, 0..) |a, i| {
1760 for (methods[i + 1 ..]) |b| {
1761 try testing.expect(!std.mem.eql(u8, a.selector, b.selector));
1762 }
1763 }
1764}
1765
1766test "a swipe-down that sends no cancel still empties the slot exactly once" {
1767 // The belt's contract, as state rather than as prose: whichever callback
1768 // arrives first takes the ticket, and the second finds nothing. Without it a
1769 // swipe would leave `pending` set for the life of the process and refuse
1770 // every later pickContact — which is worse here than in Swift, where only
1771 // the one promise is stranded.
1772 resetPendingForTesting();
1773 defer resetPendingForTesting();
1774
1775 try testing.expect(publishPending(.{ .ticket = fakeTicket(2, 5), .sels = fakeSels() }));
1776
1777 // The dismissal gets there first: it takes the ticket...
1778 const swiped = takePending() orelse return error.PendingWentMissing;
1779 try testing.expectEqual(@as(u5, 2), swiped.ticket.index);
1780
1781 // ...and a late `contactPickerDidCancel:` for the same picker finds nothing
1782 // to answer, so the promise is rejected once rather than twice.
1783 try testing.expect(takePending() == null);
1784
1785 // The slot is free again, not wedged.
1786 try testing.expect(publishPending(.{ .ticket = fakeTicket(2, 7), .sels = fakeSels() }));
1787}
1788
1789test "nothing in this module dismisses the picker" {
1790 // `CNContactPickerViewController` is a remote view controller that takes
1791 // itself down, and Swift dismisses it in none of its three delegate methods.
1792 // A `dismiss` added here would be a second dismissal of a controller already
1793 // dismissing — and on the presenter it would take down whatever else is
1794 // modal. This is a real difference from the image and document pickers, so
1795 // it is asserted rather than left to a comment.
1796 //
1797 // `presentationControllerDidDismiss:` does not contradict this: it *observes*
1798 // a dismissal the user performed and never asks for one.
1799 //
1800 // The needle is the *call* form — a `selector(` with a dismissal name in it
1801 // — rather than the bare selector name, so the prose in this file cannot
1802 // match itself and the assertion keeps meaning something. Which is why the
1803 // needle is not spelled out in any comment, including this one.
1804 const source = @embedFile("bridge_mobile_contactpicker.zig");
1805 try testing.expect(std.mem.indexOf(u8, source, "selector(\"dismiss") == null);
1806 // Non-vacuity: the same scan finds the present call, so a needle that
1807 // stopped matching anything could not pass unnoticed.
1808 try testing.expect(std.mem.indexOf(u8, source, "selector(\"presentViewController") != null);
1809}
packages/zig/src/bridge_mobile_filepicker.zigadded+1950-0
Changes to packages/zig/src/bridge_mobile_filepicker.zig
@@ -0,0 +1,1950 @@
1//! The `mobile` namespace's two file actions: `pickFile` and `saveFile`.
2//!
3//! They look like a pair and are not. `pickFile` presents
4//! `UIDocumentPickerViewController`, so its answer arrives from a delegate
5//! callback long after the dispatch frame is gone and needs an `ios_async`
6//! ticket. `saveFile` presents nothing at all — it writes straight into
7//! `<container>/Documents/<filename>` and resolves the path from inside the
8//! dispatch. The third member of the family, `downloadFile`, is deliberately
9//! left with the Swift shim; see `deliberately_unserved`.
10//!
11//! ## Neither action has any injected JavaScript, so the field names come from
12//! ## the Swift dispatcher
13//!
14//! `window.craft.pickFile` and `window.craft.saveFile` **do not exist** in the
15//! injected iOS bridge. `injectNativeBridge` spans `CraftApp.swift:1164`-`2426`
16//! and contains no occurrence of `file` at all, and `craft-bridge.js` has no
17//! `mobile` surface either (its `saveFile` is `_req('dialog','saveFile',…)` —
18//! the desktop dialog namespace, a different action in a different namespace).
19//! So `test-bridges.html:585` and both README examples are `TypeError`s on the
20//! legacy Swift path, and the dispatcher arms are reachable only through the
21//! Zig envelope `{t:"mobile", a:"pickFile"|"saveFile", d, i}`. That is a
22//! pre-existing bug, the same one `bridge_mobile_motion.zig` records for
23//! `startMotionUpdates`, and it is why the payload field names below are read
24//! off the Swift dispatcher's own `body[...]` reads and cross-checked against
25//! `craft.d.ts` and Android's `CraftBridge.kt.template` rather than off an
26//! injected method that was never written.
27//!
28//! ## The payloads, exactly
29//!
30//! **`pickFile`** reads one optional field, `types`, an array of strings
31//! (`CraftApp.swift:817`). Nothing else is read. Absent or null means Swift's
32//! default of a single `UTTypeItem`.
33//!
34//! **`saveFile`** reads two required fields, `data` and `filename`
35//! (`CraftApp.swift:829-831`). There is no third field: `craft.d.ts:217`
36//! declares a `mimeType` parameter and the README passes one, but neither
37//! Swift nor Android ever reads it. It is a type-declaration fiction, so a
38//! `mimeType` in the payload is accepted and ignored rather than invented into
39//! a field or turned into an error.
40//!
41//! ## The reply shapes, exactly
42//!
43//! **`pickFile` resolves an object** (`CraftApp.swift:4897-4902`):
44//! `{"name":…,"path":…,"data":"data:<mime>;base64,<b64>","mimeType":…}`. The
45//! `data:` URL uses the same derived MIME string, and the base64 is
46//! `-[NSData base64EncodedStringWithOptions:0]` — standard alphabet, padded,
47//! unwrapped. Key order is Swift's literal order; Swift's own is a `Dictionary`
48//! and therefore arbitrary, so one order is fixed here to make the bytes
49//! testable.
50//!
51//! **`pickFile` rejects on cancel.** `documentPickerWasCancelled:` calls
52//! `rejectCallback(pendingCallbackId, error: "Cancelled")`
53//! (`CraftApp.swift:4913`), so a cancel is a rejected promise and not a
54//! distinct resolve — the opposite of the shape a picker often has, and worth
55//! reading twice. An empty `urls` array is likewise a reject
56//! (`"No file selected"`, `CraftApp.swift:4889`).
57//!
58//! **`saveFile` resolves a bare JSON string**, the written path
59//! (`CraftApp.swift:3658` through `resolveCallback`'s `.fragmentsAllowed`).
60//! Not an object. `craft.d.ts:217` types it `Promise<void>`, but Android agrees
61//! with Swift (`file.absolutePath`), and `craft-bridge.js` settles with
62//! `payload || {}` — a non-empty path survives that, and the path can never be
63//! empty here, so there is no truthiness trap.
64//!
65//! ## The config gate Zig cannot see, and why both actions are served anyway
66//!
67//! Swift wraps both arms in `if config.enableFilePicker` /
68//! `if config.enableFileDownload`, each defaulting to **false**
69//! (`CraftApp.swift:206-207`), each with **no `else`**. So with the default
70//! config the Swift path replies *nothing at all* while
71//! `CraftSwiftShim.handleAction` still returns true: the page's promise never
72//! settles. `ios.zig`'s `AppConfig` has no mirror of either flag, and — unlike
73//! motion, where `NSMotionUsageDescription` is an exact proxy — there is no
74//! Info.plist key to read instead: `renderUsageDescriptions`
75//! (`packages/ios/src/index.ts:183-199`) writes nothing from either flag.
76//!
77//! Both are therefore served unconditionally, and an app that set
78//! `enableFilePicker: false` or `enableFileDownload: false` now gets the
79//! behaviour anyway. Restoring the gate means adding the fields to
80//! `AppConfig`, not adding a guess here — the same trade
81//! `bridge_mobile_system.zig` records word for word for `share`.
82//!
83//! Falling through to the shim is not the safe option it was for
84//! `takeScreenshot`, because the shim does not answer these correctly today:
85//! with the flag off it answers with silence, and `ios_dispatch.zig:170-174`
86//! names silence as the one outcome ruled out. `.unavailable` is strictly
87//! worse again — `ios_dispatch.route` only reaches the shim when every module
88//! returns `UnknownAction`, so declaring it would make Zig dispatch and refuse.
89//!
90//! What makes serving them defensible rather than merely better-than-silence:
91//! `pickFile` is user-mediated, so nothing is read without the user tapping a
92//! file, and `saveFile` writes only inside the app's own container, needs no
93//! OS permission and shows no UI.
94//!
95//! ## Five deliberate divergences from Swift
96//!
97//! **1. Security-scoped access is taken around the read.** Swift's
98//! `documentPicker(_:didPickDocumentsAt:)` is a bare `try? Data(contentsOf: url)`
99//! with no `startAccessingSecurityScopedResource` anywhere in the repo. Because
100//! the picker is built with `asCopy == NO`, the URL is a security-scoped
101//! reference to a document outside the container: on a device that read
102//! **fails**, `try?` yields nil, and Swift falls to its degraded branch. In the
103//! simulator the same read usually succeeds, which is why the bug has never
104//! been noticed. This module takes the scope and releases it in a `defer`.
105//! Swift does *not* do this; the claim is only about what this file does.
106//!
107//! **2. A file whose bytes cannot be read is rejected, not resolved.** Swift
108//! resolves `{name, path}` with no `data` and no `mimeType`
109//! (`CraftApp.swift:4903-4906`) — without the field its own `PickedFile` type
110//! declares required and the only in-repo consumer reads
111//! (`test-bridges.html:589` reads `file?.data?.length`). With the scoping fix
112//! above, a failed read is a real failure, and resolving a data-less object for
113//! it is fabricated-adjacent success. It is a rejection here.
114//!
115//! **3. An unmappable `types` entry is refused rather than dropped.** Swift's
116//! `compactMap` silently discards entries it cannot map, and can therefore
117//! produce an *empty* allowed-types array. Dropping a field the page sent is
118//! the failure rule 2 exists for, so an entry that maps to nothing is an
119//! `INVALID_PARAMETER` naming it.
120//!
121//! **4. `+[UTType typeWithIdentifier:]` is tried as a third fallback**, after
122//! Swift's `typeWithMIMEType:` then `typeWithFilenameExtension:`. This is an
123//! intentional improvement, declared rather than slipped in: Swift never tries
124//! it, so a bare type identifier maps to nothing at all there. It never widens
125//! past what the page asked for — an entry that names a declared identifier
126//! becomes exactly that type, and one that names nothing is still refused.
127//!
128//! What it does *not* do is rescue the README's own documented call. Measured
129//! on the host UniformTypeIdentifiers: `public.image` maps by identifier, but
130//! `public.pdf` is not a declared identifier at all (the real one is
131//! `com.adobe.pdf`) and maps by none of the three routes, so
132//! `pickFile(['public.image','public.pdf'])` is refused by divergence 3 — where
133//! Swift silently `compactMap`s both away and presents a picker with an empty
134//! allowed-types array. The refusal is the honest answer; the README is what
135//! needs fixing, and that is not this file's change to make.
136//!
137//! One measured caveat on divergence 3's reach: `typeWithFilenameExtension:`
138//! answers a *dynamic* type (`dyn.…`) for an arbitrary dot-free string, so a
139//! garbage entry like `"notanextension"` is accepted as that dynamic type
140//! rather than refused. Nothing is fabricated — the picker simply matches no
141//! file — and it is exactly what Swift's second `??` arm does too; the refusal
142//! path is reached by dotted non-identifiers, which is where Swift's silent
143//! drop actually bites.
144//!
145//! **5. `filename` is checked before it is joined.** Swift applies
146//! `appendingPathComponent` to an unsanitised page-supplied string
147//! (`CraftApp.swift:3645`), so `"../Library/Preferences/x.plist"` escapes
148//! `Documents`. A name containing `/` or a NUL, an empty name, and the two
149//! names `.` and `..` are refused here. With `/` excluded, `..` can only ever
150//! be the whole name, so those four checks are exhaustive for traversal — and
151//! an ordinary name like `my..notes.txt` still passes.
152//!
153//! And one bug that is simply not reproduced: Swift's `saveFile` reaches
154//! `resolveCallback(callbackId, result: fileURL.path)` even when the data URL
155//! had the wrong number of comma-separated parts or its base64 would not decode
156//! (`CraftApp.swift:3651-3658` — the `if` body just never runs, nothing throws).
157//! `data:text/plain,hello` triggers it: the page is told a file was written
158//! where nothing was. Here both are `INVALID_PARAMETER`.
159//!
160//! ## Dismissal
161//!
162//! `UIDocumentPickerViewController` dismisses itself before calling its
163//! delegate — which is why Swift's two delegate methods contain no `dismiss`
164//! call, in visible contrast to the image picker, which dismisses explicitly at
165//! `CraftApp.swift:2643` and `2662`. Sending an unconditional dismiss would
166//! therefore be a *second* dismiss, and a second dismiss is not a no-op:
167//! `-dismissViewControllerAnimated:completion:` on a controller with nothing
168//! presented forwards up the presentation chain and can tear down something
169//! nobody asked to close.
170//!
171//! So both callbacks dismiss, and both check first: `dismissPicker` reads the
172//! controller's own `presentingViewController` and does nothing when it is nil
173//! (already dismissed itself). When it is non-nil the picker really is still on
174//! screen and is dismissed by sending the message to the picker itself, which
175//! UIKit routes to its presenter. The app cannot end up parked behind a modal,
176//! and nothing unrelated is dismissed either.
177//!
178//! ## The swipe-down belt
179//!
180//! A modal the delegate never hears about is the other half of the problem, and
181//! it is the *page* rather than the app that pays for it. The picker is a sheet,
182//! and an interactive dismissal is widely reported not to send
183//! `documentPickerWasCancelled:` — the same report `bridge_mobile_imagepicker.zig`
184//! and `bridge_mobile_contactpicker.zig` carry belts for, and one this repo
185//! verifies on no device. Without a belt a swipe leaves `pending_pick` set
186//! forever: the call is never answered (silence, which `ios_dispatch.zig:170-174`
187//! rules out) **and** every later `pickFile` is refused for the life of the
188//! process, which is strictly worse than Swift, where only the one promise is
189//! stranded.
190//!
191//! `presentationControllerDidDismiss:` is that belt: it is registered on the
192//! same delegate, and the picker's presentation controller is pointed at that
193//! delegate before presenting and again after, so a swipe *rejects* the call
194//! rather than stranding it. Belt, not fix — UIKit sends it only for a
195//! user-driven dismissal, so it cannot fire after a pick or a Cancel that
196//! already answered, and if the report is wrong it costs two messages. The one
197//! case it does not cover is a presentation controller that already carries a
198//! delegate of its own, which is left alone rather than displaced; that is
199//! logged where it happens rather than assumed away.
200//!
201//! ## One pending pick, refused rather than replaced
202//!
203//! Swift keeps a single `pendingCallbackId` (`CraftApp.swift:383`) shared
204//! across the camera, the image picker, the document picker, the contact
205//! picker, the QR scanner, Apple Sign-In, NFC and Bluetooth, and overwrites it
206//! unconditionally — so two overlapping picks strand the first promise forever.
207//! A picker is modal and serves exactly one call, so this module keeps exactly
208//! one pending ticket for this picker kind, under a mutex, and a second
209//! `pickFile` issued while one is presented is an explicit error to the
210//! *second* caller. The first ticket is never replaced.
211//!
212//! ## Ceilings and simulator caveats
213//!
214//! The whole file crosses the bridge base64-encoded inside one
215//! `evaluateJavaScript` string — about 1.37 bytes of JavaScript source per byte
216//! of file. Swift has exactly the same ceiling; it is noted here rather than
217//! discovered on a 200 MB video.
218//!
219//! `UIDocumentPickerViewController` itself works in the simulator — it is not
220//! camera-class hardware, so the "unavailable on simulator, must reject"
221//! precedent does not apply. What the simulator lacks is content to pick, and
222//! the `zig-slice` fixture cannot tap Cancel or choose a file anyway, so only
223//! `pickFile`'s refusal paths are reachable from that harness. `saveFile` is
224//! fully fixture-testable. Security scoping is invisible in the simulator,
225//! where URLs resolve to ordinary host paths and the read succeeds without it —
226//! a green simulator run proves nothing about divergence 1.
227//!
228//! `objc_getClass("UTType")` may be null in the Zig-only fixture:
229//! `packages/ios/fixtures/zig-slice/build-and-run.sh:65` links only
230//! UIKit/WebKit/Foundation/Security, while the generated app pulls
231//! UniformTypeIdentifiers in through `CraftApp.swift:20`. A null class is a
232//! named error with a log line naming the missing framework — never a silent
233//! fallback to the `[.item]` default, which would quietly widen the file types
234//! the app author asked to restrict.
235
236const std = ;
237const builtin = ;
238const capabilities = ;
239const bridge_error = ;
240const objc_runtime = ;
241const ios_async = ;
242const ios_delegate = ;
243const bridge_mobile_system = ;
244const compat_mutex = ;
245
246const objc = objc_runtime.objc;
247const is_darwin = builtin.target.os.tag.isDarwin();
248const BridgeError = bridge_error.BridgeError;
249
250/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
251///
252/// `objc_runtime.objc` is an empty struct off Darwin, and a `callconv(.c)`
253/// fn-pointer *type* is analysed even where a comptime platform guard makes the
254/// surrounding code unreachable, so naming `objc.id` in the delegate signatures
255/// below would break the host build. A single optional pointer, never
256/// `?objc.id`: a double optional is illegal in a `callconv(.c)` signature.
257const Id = ?*anyopaque;
258
259/// The action names, spelled exactly as the Swift `case` labels spell them.
260///
261/// `test/ios_conformance_test.zig` matches this block against those labels in
262/// both directions and fails the build if two modules declare one name. Keep
263/// the doc comments in this block free of quoted text: the scanner takes the
264/// first quoted string on every line it sees between the header and the closing
265/// brace.
266pub const A = struct {
267 pub const pick_file = "pickFile";
268 pub const save_file = "saveFile";
269};
270
271/// The third action of this family, and on purpose not served.
272///
273/// `downloadFile` is out of scope for this round. Recorded as data rather than
274/// prose so a test can hold the two properties the decision rests on: it is not
275/// declared, and it still falls out of `handleMessage` as `UnknownAction` —
276/// the one return `ios_dispatch.route` converts into a Swift-shim hand-off.
277/// Whoever migrates it moves the name into `A` and updates the module comment.
278pub const deliberately_unserved = [_][]const u8{
279 "downloadFile",
280};
281
282/// Both `.result`: each Swift path terminates in exactly one `resolveCallback`
283/// or one `rejectCallback`, and the page awaits both. `.none` on either would
284/// resolve a caller immediately with nothing.
285///
286/// Both `.live`: a refusal here is a specific failure — a cancelled pick, an
287/// unreadable file, a filename that would escape the container, a full async
288/// pool — never the action's normal answer. `.unavailable` is for an action
289/// that dispatches and refuses, which neither of these is.
290pub const capability_actions = [_]capabilities.ActionDecl{
291 .{ .name = A.pick_file, .reply = .result },
292 .{ .name = A.save_file, .reply = .result },
293};
294
295/// Which handler an action selects, split out of `handleMessage` so the
296/// table-versus-dispatch agreement is assertable on a host without UIKit.
297const Route = enum { pick_file, save_file };
298
299fn routeFor(action: []const u8) ?Route {
300 if (std.mem.eql(u8, action, A.pick_file)) return .pick_file;
301 if (std.mem.eql(u8, action, A.save_file)) return .save_file;
302 return null;
303}
304
305pub const FilePickerBridge = struct {
306 allocator: std.mem.Allocator,
307
308 const Self = @This();
309
310 pub fn init(allocator: std.mem.Allocator) Self {
311 return .{ .allocator = allocator };
312 }
313
314 pub fn deinit(_: *Self) void {}
315
316 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
317 const route = routeFor(action) orelse return BridgeError.UnknownAction;
318 // Exhaustive, so a `Route` without a handler is a compile error.
319 return switch (route) {
320 .pick_file => self.pickFile(data),
321 .save_file => self.saveFile(data),
322 };
323 }
324
325 /// Present the document picker and answer when the user has chosen or
326 /// cancelled.
327 ///
328 /// Every fallible step runs *before* `ios_async.acquire`, and the only
329 /// thing between publishing the ticket and presenting is the presentation
330 /// itself. A failure after the lease would have to release the slot by
331 /// hand, and a missed release is a permanently narrower pool.
332 fn pickFile(self: *Self, data: []const u8) !void {
333 if (!is_darwin) return error.UnsupportedPlatform;
334
335 const request = try parsePickRequest(self.allocator, data);
336 defer request.deinit(self.allocator);
337
338 // Guarded once, here, for both uses below: the default `public.item`
339 // and every mapped entry need this class. A null answer means the
340 // process has no UniformTypeIdentifiers — the fixture's link line — and
341 // that is an error, never a silent fall back to the default, which
342 // would widen the types the app author asked to restrict.
343 const UTTypeClass = objc.objc_getClass("UTType") orelse {
344 std.log.err(
345 "pickFile: this process has no UTType class, so no content type can be " ++
346 "built; the app or fixture needs -framework UniformTypeIdentifiers",
347 .{},
348 );
349 return BridgeError.NativeCallFailed;
350 };
351
352 const content_types = try buildContentTypes(self.allocator, request, UTTypeClass);
353 const delegate = try ensureDelegate();
354
355 const PickerClass = objc.objc_getClass("UIDocumentPickerViewController") orelse
356 return error.ClassNotFound;
357
358 // `initForOpeningContentTypes:` — the one-argument iOS 14+ form, which
359 // is what Swift's single-label call binds to, i.e. `asCopy == NO`.
360 // Deployment target is 16.0, so its availability is unconditional. The
361 // deprecated `initWithDocumentTypes:inMode:` is not used.
362 const sel_init = try selector("initForOpeningContentTypes:");
363 const sel_set_delegate = try selector("setDelegate:");
364 const sel_set_multi = try selector("setAllowsMultipleSelection:");
365 const sel_present = try selector("presentViewController:animated:completion:");
366 // The swipe-down belt's reads, resolved here with everything else so a
367 // missing selector fails the call rather than silently leaving the only
368 // thing that answers an interactive dismissal unattached.
369 const dismiss_sels = DismissSels{
370 .presentation = try selector("presentationController"),
371 .delegate = try selector("delegate"),
372 .set_delegate = sel_set_delegate,
373 };
374
375 const allocated = try objc.alloc(PickerClass);
376 const InitFn = *const fn (Id, Id, Id) callconv(.c) Id;
377 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
378 const picker = init_fn(allocated, sel_init, content_types) orelse
379 return error.NativeCallFailed;
380 // `alloc` gave us +1 and `init` consumed it. The presenter retains the
381 // picker for the life of the presentation, so ours is the reference to
382 // drop — and on the refusal paths below it is the only one there is.
383 defer objc.release(picker);
384
385 // The picker holds its delegate **weakly** (`UIDocumentPickerViewController.h:65`),
386 // which is why `delegate_instance` is a module-level var rather than a
387 // local: dropping it would be a use-after-free the first time the user
388 // taps Cancel.
389 objc.msgSendVoid1(picker, sel_set_delegate, delegate);
390
391 const SetBoolFn = *const fn (Id, Id, bool) callconv(.c) void;
392 const set_bool: SetBoolFn = @ptrCast(&objc.objc_msgSend);
393 // Swift sets this explicitly at `CraftApp.swift:3603` even though false
394 // is the default, and the reply shape has room for exactly one file.
395 set_bool(picker, sel_set_multi, false);
396
397 // Swift presents on `windowScene.windows.first?.rootViewController`
398 // (`CraftApp.swift:3594`), which logs "which is already presenting" and
399 // silently does nothing when anything else is up — leaving the promise
400 // unsettled. This walks `presentedViewController` from the webview's own
401 // window instead. A declared deviation, and strictly the more correct
402 // one. No `anchorPopover` call: the picker's default presentation style
403 // is a form/page sheet, so `popoverPresentationController` is nil and
404 // there is nothing to anchor — unlike `UIActivityViewController`, which
405 // traps without one.
406 const presenter = try bridge_mobile_system.uikit.topmostViewController();
407
408 // Attached before presenting, because that is when a presentation
409 // controller can already exist, and retried after `presentViewController:`
410 // for the case where UIKit only builds one there. Infallible either way:
411 // it reports whether it took and never fails the call.
412 var dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels);
413
414 const ticket = ios_async.acquire(A.pick_file) orelse return poolFull();
415
416 // Published before the framework call, never after: the delegate can in
417 // principle be reached before `present` returns, and a callback that
418 // arrived at an empty slot would have no ticket to reply with.
419 if (!publishPendingPick(ticket)) {
420 // Swift overwrites its one `pendingCallbackId` here and the first
421 // caller's promise never settles. The second caller is refused
422 // instead; the first keeps the picker and its ticket.
423 ios_async.abandon(ticket);
424 std.log.warn(
425 "pickFile refused: a document picker is already presented and owns the " ++
426 "pending reply; refusing the second call rather than stranding the first",
427 .{},
428 );
429 return BridgeError.InvalidParameter;
430 }
431
432 const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void;
433 const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend);
434 present_fn(presenter, sel_present, picker, true, null);
435
436 if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels);
437 if (!dismiss_observed) {
438 std.log.warn(
439 "pickFile: the picker's presentation controller could not be observed " ++
440 "(absent, or already carrying a delegate this file will not displace); " ++
441 "if a swipe-down dismissal does not send documentPickerWasCancelled: " ++
442 "then this call stays unanswered and every later one is refused",
443 .{},
444 );
445 }
446 }
447
448 /// Write `data` into `<container>/Documents/<filename>` and resolve the
449 /// path.
450 ///
451 /// Fully synchronous: Swift presents nothing here — no export-mode picker,
452 /// no temp file, no delegate — so there is no `ios_async` ticket and the
453 /// reply goes out from inside the dispatch frame that holds the request id.
454 fn saveFile(self: *Self, data: []const u8) !void {
455 if (!is_darwin) return error.UnsupportedPlatform;
456
457 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
458 return BridgeError.InvalidJSON;
459 defer parsed.deinit();
460
461 const root = switch (parsed.value) {
462 .object => |obj| obj,
463 else => return BridgeError.InvalidJSON,
464 };
465
466 const request = try parseSaveRequest(root);
467
468 const bytes = try decodeSaveBytes(self.allocator, request.data);
469 defer self.allocator.free(bytes);
470
471 const directory = try documentsPath(self.allocator);
472 defer self.allocator.free(directory);
473
474 const path = try std.fmt.allocPrint(
475 self.allocator,
476 "{s}/{s}",
477 .{ directory, request.filename },
478 );
479 defer self.allocator.free(path);
480
481 try writeFileAtomically(self.allocator, path, bytes);
482
483 const json = try shapeSavedPath(self.allocator, path);
484 defer self.allocator.free(json);
485
486 bridge_error.sendResultToJS(self.allocator, A.save_file, json);
487 }
488};
489
490/// The answer for a full block pool, in the shape `bridge_mobile_location.zig`
491/// uses: `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration
492/// notes' designated stand-in, and the point is that the seventeenth concurrent
493/// call gets an explicit rejection instead of a promise that never settles.
494fn poolFull() BridgeError {
495 std.log.warn("pickFile refused: all {d} async slots in flight", .{ios_async.max_in_flight});
496 return BridgeError.InvalidParameter;
497}
498
499fn selector(name: [*:0]const u8) !Id {
500 if (!is_darwin) return error.UnsupportedPlatform;
501 return objc.sel_registerName(name) orelse error.SelectorNotFound;
502}
503
504// =============================================================================
505// Payload parsing. Pure — no Objective-C — so every field the page can send is
506// driven by host tests on every platform.
507// =============================================================================
508
509/// What `pickFile` read out of the payload.
510const PickRequest = struct {
511 /// Null when the page sent no `types`, or sent null — Swift's `[.item]`
512 /// default. Never an empty slice: an empty `types` array is refused at
513 /// parse, so `buildContentTypes` can rely on having at least one entry.
514 types: ?[][]const u8,
515
516 fn deinit(self: PickRequest, allocator: std.mem.Allocator) void {
517 const list = self.types orelse return;
518 for (list) |entry| allocator.free(entry);
519 allocator.free(list);
520 }
521};
522
523/// `types`, and nothing else — Swift reads no other key out of `body` for this
524/// action, and inventing one would create a way for the call to fail that the
525/// shim does not have.
526///
527/// The entries are copied because the caller frees the parsed JSON before the
528/// picker is built.
529fn parsePickRequest(allocator: std.mem.Allocator, data: []const u8) !PickRequest {
530 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
531 return BridgeError.InvalidJSON;
532 defer parsed.deinit();
533
534 const root = switch (parsed.value) {
535 .object => |obj| obj,
536 else => return BridgeError.InvalidJSON,
537 };
538
539 const value = root.get("types") orelse return .{ .types = null };
540 const array = switch (value) {
541 // `body["types"] as? [String]` yields nil for JSON null, and nil is
542 // exactly the default case.
543 .null => return .{ .types = null },
544 .array => |items| items,
545 else => {
546 std.log.warn("pickFile refused: types is present but is not an array", .{});
547 return BridgeError.InvalidParameter;
548 },
549 };
550
551 if (array.items.len == 0) {
552 // What `initForOpeningContentTypes:` does with an empty array is not
553 // documented and was not verified, and Swift can reach it by way of a
554 // `compactMap` that dropped everything. Refusing beats presenting a
555 // picker whose behaviour nobody has established.
556 std.log.warn("pickFile refused: types is an empty array", .{});
557 return BridgeError.InvalidParameter;
558 }
559
560 var list = try allocator.alloc([]const u8, array.items.len);
561 var filled: usize = 0;
562 errdefer {
563 for (list[0..filled]) |entry| allocator.free(entry);
564 allocator.free(list);
565 }
566
567 for (array.items) |item| {
568 const text = switch (item) {
569 .string => |s| s,
570 else => {
571 std.log.warn("pickFile refused: types contains a non-string entry", .{});
572 return BridgeError.InvalidParameter;
573 },
574 };
575 list[filled] = try allocator.dupe(u8, text);
576 filled += 1;
577 }
578
579 return .{ .types = list };
580}
581
582/// What `saveFile` read out of the payload. Both slices borrow the parsed JSON,
583/// which the caller keeps alive for the whole write.
584const SaveRequest = struct {
585 data: []const u8,
586 filename: []const u8,
587};
588
589/// `data` and `filename`, both required, and deliberately no third field.
590///
591/// A `mimeType` in the payload is neither read nor refused: `craft.d.ts` and the
592/// README pass one, Swift and Android both ignore it, and erroring on it would
593/// break the documented call for a field that has never meant anything.
594fn parseSaveRequest(root: std.json.ObjectMap) !SaveRequest {
595 const data_value = root.get("data") orelse {
596 std.log.warn("saveFile refused: no data field", .{});
597 return BridgeError.MissingData;
598 };
599 const filename_value = root.get("filename") orelse {
600 std.log.warn("saveFile refused: no filename field", .{});
601 return BridgeError.MissingData;
602 };
603
604 const data = switch (data_value) {
605 .string => |s| s,
606 else => {
607 std.log.warn("saveFile refused: data is not a string", .{});
608 return BridgeError.InvalidParameter;
609 },
610 };
611 const filename = switch (filename_value) {
612 .string => |s| s,
613 else => {
614 std.log.warn("saveFile refused: filename is not a string", .{});
615 return BridgeError.InvalidParameter;
616 },
617 };
618
619 try validateFilename(filename);
620
621 return .{ .data = data, .filename = filename };
622}
623
624/// Refuse a `filename` that would name a file outside `Documents`.
625///
626/// Swift joins the page's string straight on with `appendingPathComponent`, so
627/// `"../Library/Preferences/x.plist"` escapes the directory. Four checks, and
628/// with `/` excluded they are exhaustive for traversal: a `..` component can
629/// then only be the entire name. An ordinary name that merely contains two dots
630/// — `my..notes.txt` — is not refused, because it names nothing but itself.
631fn validateFilename(filename: []const u8) BridgeError!void {
632 if (filename.len == 0) {
633 std.log.warn("saveFile refused: filename is empty", .{});
634 return BridgeError.InvalidParameter;
635 }
636 if (std.mem.indexOfScalar(u8, filename, '/') != null) {
637 std.log.warn("saveFile refused: filename contains a path separator", .{});
638 return BridgeError.InvalidParameter;
639 }
640 if (std.mem.indexOfScalar(u8, filename, 0) != null) {
641 // A NUL would truncate the C string every native path API sees, so the
642 // file written would not be the file named.
643 std.log.warn("saveFile refused: filename contains a NUL byte", .{});
644 return BridgeError.InvalidParameter;
645 }
646 if (std.mem.eql(u8, filename, ".") or std.mem.eql(u8, filename, "..")) {
647 std.log.warn("saveFile refused: filename is a directory reference", .{});
648 return BridgeError.InvalidParameter;
649 }
650}
651
652/// The `data:` prefix Swift tests with `hasPrefix`.
653const data_url_prefix = "data:";
654
655/// The bytes `saveFile` writes, decoded the way Swift decodes them — minus
656/// Swift's two silent no-write paths.
657///
658/// `data:` prefixed: Swift splits on every `","` and requires exactly two parts,
659/// then base64-decodes the second. Both of those can fail, and when either does
660/// Swift writes nothing and *still resolves the path* — the page is told a file
661/// exists where nothing was written, or where a stale earlier file still sits.
662/// Both are `INVALID_PARAMETER` here.
663///
664/// Not `data:` prefixed: the string is written as UTF-8 text, which for bytes
665/// already in hand is a copy. Both branches therefore reduce to "write these
666/// bytes", which is why there is one write path below and not two.
667///
668/// `std.base64.standard.Decoder` has the same strictness as
669/// `Data(base64Encoded:)` with default options: standard alphabet, padding
670/// required, no non-alphabet characters tolerated.
671fn decodeSaveBytes(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
672 if (!std.mem.startsWith(u8, data, data_url_prefix)) {
673 return allocator.dupe(u8, data);
674 }
675
676 if (std.mem.count(u8, data, ",") != 1) {
677 std.log.warn(
678 "saveFile refused: the data URL does not split into exactly two comma-separated " ++
679 "parts, so there is nothing to decode",
680 .{},
681 );
682 return BridgeError.InvalidParameter;
683 }
684
685 const comma = std.mem.indexOfScalar(u8, data, ',').?;
686 const encoded = data[comma + 1 ..];
687
688 const decoder = std.base64.standard.Decoder;
689 const size = decoder.calcSizeForSlice(encoded) catch {
690 std.log.warn("saveFile refused: the data URL payload is not valid base64", .{});
691 return BridgeError.InvalidParameter;
692 };
693
694 const out = try allocator.alloc(u8, size);
695 errdefer allocator.free(out);
696 decoder.decode(out, encoded) catch {
697 std.log.warn("saveFile refused: the data URL payload is not valid base64", .{});
698 return BridgeError.InvalidParameter;
699 };
700 return out;
701}
702
703// =============================================================================
704// Reply shaping. Pure, so the exact bytes the page receives are pinned by host
705// tests on every platform.
706// =============================================================================
707
708/// One picked file, read out of the security-scoped URL. Every field is owned,
709/// including `mime_type` when it is the fallback, so freeing is unconditional.
710const PickedFile = struct {
711 name: []u8,
712 path: []u8,
713 mime_type: []u8,
714 /// The base64 body alone. The `data:` prefix is added by `shapePickedFile`,
715 /// so the bytes are stored once rather than concatenated twice.
716 base64: []u8,
717
718 fn deinit(self: PickedFile, allocator: std.mem.Allocator) void {
719 allocator.free(self.name);
720 allocator.free(self.path);
721 allocator.free(self.mime_type);
722 allocator.free(self.base64);
723 }
724};
725
726/// Swift's fallback when a filename extension maps to no type
727/// (`CraftApp.swift:5081-5083`), used for both the `mimeType` key and the
728/// `data:` prefix, exactly as Swift uses one string for both.
729const default_mime_type = "application/octet-stream";
730
731/// The four-key object `documentPicker(_:didPickDocumentsAt:)` resolves.
732///
733/// Key order is `name`, `path`, `data`, `mimeType` — Swift's literal order.
734/// Swift's own is a `Dictionary` and therefore arbitrary, so no caller can
735/// depend on it, but a test can only pin bytes that are deterministic.
736///
737/// `name`, `path` and `mime_type` are JSON-escaped: filenames are user
738/// controlled and legitimately contain `"` and `\`. The base64 body is appended
739/// raw, because the standard alphabet plus `=` needs no escaping — and that is
740/// *checked* rather than asserted, by the scan below, so a body that somehow
741/// carried anything else is a refusal instead of broken JavaScript in the page.
742fn shapePickedFile(allocator: std.mem.Allocator, file: PickedFile) ![]u8 {
743 if (!isStandardBase64(file.base64)) {
744 // `warn`, not `err`: this is a refusal, which is how every other
745 // refusal in the mobile modules is logged, and the host test below
746 // drives it on purpose.
747 std.log.warn(
748 "pickFile: the encoded body carries a byte outside the base64 alphabet; " ++
749 "refusing rather than emitting a reply that would not parse",
750 .{},
751 );
752 return BridgeError.NativeCallFailed;
753 }
754
755 var out: std.ArrayListUnmanaged(u8) = .empty;
756 errdefer out.deinit(allocator);
757
758 try out.appendSlice(allocator, "{\"name\":\"");
759 try bridge_error.appendJsonEscaped(allocator, &out, file.name);
760 try out.appendSlice(allocator, "\",\"path\":\"");
761 try bridge_error.appendJsonEscaped(allocator, &out, file.path);
762 try out.appendSlice(allocator, "\",\"data\":\"data:");
763 try bridge_error.appendJsonEscaped(allocator, &out, file.mime_type);
764 try out.appendSlice(allocator, ";base64,");
765 try out.appendSlice(allocator, file.base64);
766 try out.appendSlice(allocator, "\",\"mimeType\":\"");
767 try bridge_error.appendJsonEscaped(allocator, &out, file.mime_type);
768 try out.appendSlice(allocator, "\"}");
769
770 return out.toOwnedSlice(allocator);
771}
772
773fn isStandardBase64(s: []const u8) bool {
774 for (s) |c| switch (c) {
775 'A'...'Z', 'a'...'z', '0'...'9', '+', '/', '=' => {},
776 else => return false,
777 };
778 return true;
779}
780
781/// `saveFile`'s reply: the written path as a **bare JSON string**, not an
782/// object.
783///
784/// `resolveCallback` serialises with `.fragmentsAllowed`, so Swift's
785/// `result: fileURL.path` goes on the wire as a bare string, and Android agrees
786/// (`file.absolutePath`). An object here would be a shape nothing is written
787/// for.
788fn shapeSavedPath(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
789 var out: std.ArrayListUnmanaged(u8) = .empty;
790 errdefer out.deinit(allocator);
791
792 try out.append(allocator, '"');
793 try bridge_error.appendJsonEscaped(allocator, &out, path);
794 try out.append(allocator, '"');
795
796 return out.toOwnedSlice(allocator);
797}
798
799// =============================================================================
800// How a delegate callback settles the pending call.
801//
802// The resolve-versus-reject decision is rule 4's, so it lives in one pure
803// function rather than being spelled at each callback: `cancelled` mapping to
804// anything but `reject` is the single most likely way this file could go wrong,
805// and it is pinned by a host test.
806// =============================================================================
807
808/// What a document-picker callback found.
809/// The error code each rejecting outcome deserves.
810///
811/// The distinction was always here — `Outcome` separates a Cancel from an
812/// unreadable file — but there was no channel to carry it, so all four
813/// arrived as NATIVE_CALL_FAILED and the page could not tell "the user said
814/// no" from "something broke". `ios_async.deliverErrorCode` is that channel.
815///
816/// `no_selection` and `dismissed` are cancels in every sense the page cares
817/// about: nothing was picked, and nothing went wrong. Only `unreadable` is a
818/// genuine native failure.
819fn errorFor(outcome: Outcome) bridge_error.BridgeError {
820 return switch (outcome) {
821 .cancelled, .no_selection, .dismissed => bridge_error.BridgeError.Cancelled,
822 .unreadable => bridge_error.BridgeError.NativeCallFailed,
823 .picked => unreachable, // not a rejecting outcome
824 };
825}
826
827const Outcome = enum {
828 /// A file was chosen and its bytes were read.
829 picked,
830 /// The user tapped Cancel — `documentPickerWasCancelled:`.
831 cancelled,
832 /// `didPickDocumentsAtURLs:` with an empty array.
833 no_selection,
834 /// A file was chosen and something about reading or shaping it failed.
835 unreadable,
836 /// The sheet was swiped away — `presentationControllerDidDismiss:`, which
837 /// arrives when `documentPickerWasCancelled:` does not.
838 dismissed,
839};
840
841/// How the page's promise must be settled.
842const Answer = enum { resolve, reject };
843
844/// Swift's mapping, verbatim: only the read-succeeded branch resolves.
845///
846/// `cancelled` is a **reject** (`rejectCallback(pendingCallbackId, error: "Cancelled")`),
847/// not a distinct resolve. `no_selection` is Swift's `"No file selected"`
848/// reject. `unreadable` is this file's divergence 2 — Swift resolves a
849/// data-less `{name, path}` there, which reports success for a file whose
850/// contents nobody read. `dismissed` is a swipe-down: nothing was picked, so
851/// resolving anything would be success for a pick that did not happen.
852fn answerFor(outcome: Outcome) Answer {
853 return switch (outcome) {
854 .picked => .resolve,
855 .cancelled, .no_selection, .unreadable, .dismissed => .reject,
856 };
857}
858
859/// Settle whatever call this picker was serving, and nothing else.
860///
861/// Takes the pending ticket, so a second callback — a late fire, a duplicate —
862/// finds nothing and is a no-op rather than a second reply. `json` is required
863/// for `.resolve` and ignored otherwise; a `.resolve` with no payload is
864/// downgraded to an error rather than resolved empty, because
865/// `craft-bridge.js` settles with `payload || {}` and an empty resolve would
866/// look like success.
867///
868/// Never calls `evaluateJavaScript`: this runs on a framework callback, and the
869/// reply is main-thread-only. `ios_async` owns that hop.
870fn settlePick(outcome: Outcome, json: ?[]const u8) void {
871 const ticket = takePendingPick() orelse {
872 std.log.info(
873 "pickFile: a document-picker callback arrived with no call waiting; ignored",
874 .{},
875 );
876 return;
877 };
878
879 switch (answerFor(outcome)) {
880 .resolve => {
881 const payload = json orelse {
882 std.log.err("pickFile: a resolve was decided with no payload to send", .{});
883 ios_async.deliverError(ticket);
884 return;
885 };
886 ios_async.deliverJson(ticket, payload);
887 },
888 .reject => {
889 // Swift rejects with free text — "Cancelled", "No file selected" —
890 // and `sendErrorToJS` carries an enum, so the exact wording is
891 // still lost. What is no longer lost is the *kind*: `errorFor`
892 // maps a Cancel to CANCELLED and only a genuine read failure to
893 // NATIVE_CALL_FAILED, so a page can tell "the user said no" from
894 // "something broke" without reading a log.
895 ios_async.deliverErrorCode(ticket, errorFor(outcome));
896 },
897 }
898}
899
900// =============================================================================
901// One pending pick, guarded.
902//
903// A picker is modal and serves exactly one call, so there is one slot rather
904// than a pool. It is mutex-guarded even though WebKit delivers the dispatch and
905// UIKit delivers the callbacks on the main thread, because "it should always be
906// the main thread" is not a guard.
907// =============================================================================
908
909var pending_pick: ?ios_async.Ticket = null;
910var pick_mutex: compat_mutex.Mutex = .{};
911
912/// Record the call the delegate will answer. False means one is already
913/// recorded, and the caller must refuse — never replace, which would strand the
914/// first caller's promise forever.
915fn publishPendingPick(ticket: ios_async.Ticket) bool {
916 pick_mutex.lock();
917 defer pick_mutex.unlock();
918 if (pending_pick != null) return false;
919 pending_pick = ticket;
920 return true;
921}
922
923/// Read and clear the slot. Clearing is what makes a second callback a no-op
924/// rather than a second reply, and what lets the next `pickFile` through.
925fn takePendingPick() ?ios_async.Ticket {
926 pick_mutex.lock();
927 defer pick_mutex.unlock();
928 const ticket = pending_pick;
929 pending_pick = null;
930 return ticket;
931}
932
933// =============================================================================
934// The delegate class, built once through `ios_delegate`.
935// =============================================================================
936
937/// Deliberately prefixed, following `bridge_mobile_location.zig`'s
938/// `CraftIOSLocationDelegate`: both this and any desktop registration look a
939/// class up with `objc_getClass` first, so a shared name would let whichever
940/// registered second silently adopt the other's IMPs.
941const delegate_class_name = "CraftIOSDocumentPickerDelegate";
942
943/// The plural, iOS 11+ selector. The deprecated singular
944/// `documentPicker:didPickDocumentAtURL:` is deliberately not registered —
945/// UIKit calls the plural, and registering both would give two answers to one
946/// pick on any OS that still called the old one.
947const sel_did_pick_documents = "documentPicker:didPickDocumentsAtURLs:";
948const sel_was_cancelled = "documentPickerWasCancelled:";
949/// The swipe-down belt, registered on the same delegate. iOS 13+; on anything
950/// older it is never sent. See `attachDismissObserver`.
951const sel_did_dismiss = "presentationControllerDidDismiss:";
952
953/// The delegate instance, held for the life of the process.
954///
955/// `UIDocumentPickerViewController.h:65` declares `delegate` **weak**, and
956/// `ios_delegate.instantiate` does not retain. This var is the strong
957/// reference; without it the first Cancel is a use-after-free.
958var delegate_instance: Id = null;
959
960fn ensureDelegate() !Id {
961 if (!is_darwin) return error.UnsupportedPlatform;
962 if (delegate_instance) |existing| return existing;
963
964 // Plain `fn`, not `export fn`: an exported symbol is process-global and
965 // these names are generic enough to collide with another delegate in the
966 // same binary. `class_addMethod` takes the address either way.
967 const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &.{
968 .{
969 .selector = sel_did_pick_documents,
970 .imp = @ptrCast(&documentPickerDidPickDocumentsAtURLs),
971 // `- (void)m:(id)controller :(id)urls`. A wrong encoding still
972 // registers and still dispatches, then reads arguments from the
973 // wrong registers with no crash and no compile error, which is why
974 // the shape is named rather than spelled.
975 .types = ios_delegate.enc.void_two_objects,
976 },
977 .{
978 .selector = sel_was_cancelled,
979 .imp = @ptrCast(&documentPickerWasCancelled),
980 // `- (void)m:(id)controller`.
981 .types = ios_delegate.enc.void_one_object,
982 },
983 .{
984 // `- (void)presentationControllerDidDismiss:(UIPresentationController *)pc`
985 .selector = sel_did_dismiss,
986 .imp = @ptrCast(&documentPickerDidDismissInteractively),
987 .types = ios_delegate.enc.void_one_object,
988 },
989 });
990
991 const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed;
992 delegate_instance = instance;
993 return instance;
994}
995
996/// Dismiss the picker, but only if it is still presented.
997///
998/// `UIDocumentPickerViewController` dismisses itself before calling the
999/// delegate — Swift's two methods contain no `dismiss` call, unlike the image
1000/// picker's. An unconditional dismiss would therefore be a second one, and
1001/// `-dismissViewControllerAnimated:completion:` on a controller with nothing
1002/// presented forwards up the presentation chain and can close something nobody
1003/// asked to close. Reading `presentingViewController` first is what makes this
1004/// safe in both worlds: nil means it has already gone, non-nil means it is
1005/// genuinely still up and the app would otherwise be stuck behind it.
1006fn dismissPicker(controller: Id) void {
1007 if (!is_darwin) return;
1008 if (controller == null) return;
1009
1010 const sel_presenting = objc.sel_registerName("presentingViewController") orelse return;
1011 if (objc.msgSendId(controller, sel_presenting) == null) return;
1012
1013 const sel_dismiss = objc.sel_registerName("dismissViewControllerAnimated:completion:") orelse {
1014 std.log.warn(
1015 "pickFile: no dismissViewControllerAnimated:completion: selector; the document " ++
1016 "picker is still presented",
1017 .{},
1018 );
1019 return;
1020 };
1021 const DismissFn = *const fn (Id, Id, bool, Id) callconv(.c) void;
1022 const dismiss: DismissFn = @ptrCast(&objc.objc_msgSend);
1023 dismiss(controller, sel_dismiss, true, null);
1024}
1025
1026fn documentPickerDidPickDocumentsAtURLs(_: Id, _: Id, controller: Id, urls: Id) callconv(.c) void {
1027 if (!is_darwin) return;
1028
1029 dismissPicker(controller);
1030
1031 const allocator = std.heap.c_allocator;
1032
1033 const sel_first = objc.sel_registerName("firstObject") orelse {
1034 std.log.err("pickFile: no firstObject selector; cannot read the picked URL", .{});
1035 settlePick(.unreadable, null);
1036 return;
1037 };
1038 // `urls.first` in Swift. `firstObject` answers nil for an empty array,
1039 // where `objectAtIndex:0` would raise an uncatchable NSRangeException.
1040 const url = objc.msgSendId(urls, sel_first) orelse {
1041 std.log.warn("pickFile: the document picker returned no URL", .{});
1042 settlePick(.no_selection, null);
1043 return;
1044 };
1045
1046 const file = readPickedFile(allocator, url) catch |err| {
1047 std.log.err(
1048 "pickFile: could not read the picked file ({}); rejecting rather than resolving " ++
1049 "an object with no data, which is what the Swift path does here",
1050 .{err},
1051 );
1052 settlePick(.unreadable, null);
1053 return;
1054 };
1055 defer file.deinit(allocator);
1056
1057 const json = shapePickedFile(allocator, file) catch |err| {
1058 std.log.err("pickFile: could not shape the reply ({})", .{err});
1059 settlePick(.unreadable, null);
1060 return;
1061 };
1062 defer allocator.free(json);
1063
1064 settlePick(.picked, json);
1065}
1066
1067fn documentPickerWasCancelled(_: Id, _: Id, controller: Id) callconv(.c) void {
1068 if (!is_darwin) return;
1069
1070 dismissPicker(controller);
1071
1072 // Swift's `rejectCallback(pendingCallbackId, error: "Cancelled")`. The word
1073 // survives only here — the wire carries a `BridgeError` enum.
1074 std.log.info("pickFile: cancelled by the user", .{});
1075 settlePick(.cancelled, null);
1076}
1077
1078/// The user swiped the sheet away.
1079///
1080/// Nothing is dismissed here: UIKit has already taken the sheet off the screen,
1081/// which is exactly what this callback reports, and sending
1082/// `dismissViewControllerAnimated:` now could close whatever the presenter shows
1083/// next. The only job is to settle the call.
1084///
1085/// Reached with the slot already empty whenever a real callback got there first,
1086/// in which case `settlePick` is a no-op rather than a second answer.
1087fn documentPickerDidDismissInteractively(_: Id, _: Id, _: Id) callconv(.c) void {
1088 if (!is_darwin) return;
1089
1090 std.log.warn(
1091 "pickFile: the picker was dismissed interactively; rejecting the call rather than " ++
1092 "leaving it unanswered on a promise that has no timeout",
1093 .{},
1094 );
1095 settlePick(.dismissed, null);
1096}
1097
1098/// The three selectors the swipe-down belt needs, resolved at dispatch so a
1099/// missing one fails the call rather than silently leaving the only thing that
1100/// answers an interactive dismissal unattached.
1101const DismissSels = struct {
1102 /// `-[UIViewController presentationController]`.
1103 presentation: Id,
1104 /// `-[UIPresentationController delegate]`, read so an existing delegate is
1105 /// never displaced.
1106 delegate: Id,
1107 /// `-[UIPresentationController setDelegate:]` — the same `setDelegate:` the
1108 /// picker's own delegate is installed with.
1109 set_delegate: Id,
1110};
1111
1112/// Point the picker's presentation controller at this module's delegate, so an
1113/// interactive dismissal reaches `presentationControllerDidDismiss:`. Returns
1114/// whether it was attached.
1115///
1116/// `UIDocumentPickerViewController` presents as a form/page sheet, and a
1117/// swipe-down is widely reported not to send `documentPickerWasCancelled:`.
1118/// That report is verified on no device in this repo, so this is a belt and not
1119/// a fix: it costs two messages, and if the report is right it is the only
1120/// thing that ever frees `pending_pick` after a swipe. It matters more here than
1121/// in Swift, because this module *refuses* later calls while one is pending — a
1122/// stranded ticket disables `pickFile` for the life of the process, where Swift
1123/// merely strands the one promise. It is also the same belt
1124/// `bridge_mobile_imagepicker.zig` and `bridge_mobile_contactpicker.zig` carry.
1125///
1126/// A presentation controller that already carries a delegate is left alone and
1127/// reported as not attached: displacing one would change how the picker manages
1128/// its own adaptive presentation, which is a new bug in exchange for a belt.
1129///
1130/// Idempotent against the real cancel. UIKit sends
1131/// `presentationControllerDidDismiss:` only for a user-driven dismissal, never
1132/// for a programmatic one, so it cannot follow a pick or a Cancel that already
1133/// answered — and if it did anyway, `takePendingPick` has already emptied the
1134/// slot and `ios_async`'s generation counter would drop the second delivery.
1135fn attachDismissObserver(picker: Id, delegate: Id, sels: DismissSels) bool {
1136 if (!is_darwin) return false;
1137
1138 const presentation = objc.msgSendId(picker, sels.presentation) orelse return false;
1139 if (objc.msgSendId(presentation, sels.delegate) != null) return false;
1140
1141 objc.msgSendVoid1(presentation, sels.set_delegate, delegate);
1142 return true;
1143}
1144
1145// =============================================================================
1146// Reading the picked file.
1147// =============================================================================
1148
1149/// Read one security-scoped document URL into the four fields the reply needs.
1150///
1151/// The `start`/`stop` pair around the read is the fix described as divergence 1
1152/// in the module comment: the picker was built with `asCopy == NO`, so this URL
1153/// points outside the app container and the read fails on a device without it.
1154/// It is invisible in the simulator, where the URL resolves to an ordinary host
1155/// path and the read succeeds either way — so a green simulator run says
1156/// nothing about this call.
1157fn readPickedFile(allocator: std.mem.Allocator, url: Id) !PickedFile {
1158 if (!is_darwin) return error.UnsupportedPlatform;
1159
1160 const sel_start = try selector("startAccessingSecurityScopedResource");
1161 const sel_stop = try selector("stopAccessingSecurityScopedResource");
1162 const sel_last_component = try selector("lastPathComponent");
1163 const sel_path = try selector("path");
1164 const sel_extension = try selector("pathExtension");
1165 const sel_data_with_url = try selector("dataWithContentsOfURL:");
1166 const sel_base64 = try selector("base64EncodedStringWithOptions:");
1167
1168 // BOOL, and the return matters: the stop must be sent only when the start
1169 // succeeded, because the access counter is balanced.
1170 const scoped = objc.msgSendBool(url, sel_start);
1171 defer if (scoped) objc.msgSend(url, sel_stop);
1172
1173 const name_obj = objc.msgSendId(url, sel_last_component) orelse return error.NativeCallFailed;
1174 const name = try copyNSString(allocator, name_obj);
1175 errdefer allocator.free(name);
1176
1177 const path_obj = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed;
1178 const path = try copyNSString(allocator, path_obj);
1179 errdefer allocator.free(path);
1180
1181 const extension_obj = objc.msgSendId(url, sel_extension);
1182 const mime_type = try copyMimeType(allocator, extension_obj);
1183 errdefer allocator.free(mime_type);
1184
1185 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
1186 // Nil here is Swift's `try?` yielding nil, which is where it resolves an
1187 // object with no `data`. This is the branch that fails on a device without
1188 // the security scope above.
1189 const data_obj = objc.msgSendId1(NSData, sel_data_with_url, url) orelse
1190 return error.FileContentsUnreadable;
1191
1192 // Options 0 — exactly `Data.base64EncodedString()`: standard alphabet,
1193 // padded, no line wrapping.
1194 const base64_obj = objc.msgSendId1(data_obj, sel_base64, @as(c_ulong, 0)) orelse
1195 return error.NativeCallFailed;
1196 const base64 = try copyNSString(allocator, base64_obj);
1197
1198 return .{ .name = name, .path = path, .mime_type = mime_type, .base64 = base64 };
1199}
1200
1201/// `URL.mimeType` from `CraftApp.swift:5078-5085`: the filename extension's
1202/// preferred MIME type, or `application/octet-stream`.
1203///
1204/// A nil at either step is Swift's fallback and not an error — a file with no
1205/// extension, or an extension no type claims, is an ordinary file. The `UTType`
1206/// class *is* looked up again here and a null answer is an error rather than a
1207/// fallback, which is unreachable in practice (`pickFile` refused before
1208/// presenting if the class was absent) but is a guard rather than an assumption.
1209fn copyMimeType(allocator: std.mem.Allocator, extension_obj: Id) ![]u8 {
1210 if (!is_darwin) return error.UnsupportedPlatform;
1211
1212 const fallback = try allocator.dupe(u8, default_mime_type);
1213 errdefer allocator.free(fallback);
1214
1215 if (extension_obj == null) return fallback;
1216
1217 const UTTypeClass = objc.objc_getClass("UTType") orelse return error.ClassNotFound;
1218 const sel_with_extension = try selector("typeWithFilenameExtension:");
1219 const sel_preferred = try selector("preferredMIMEType");
1220
1221 const ut_type = objc.msgSendId1(UTTypeClass, sel_with_extension, extension_obj) orelse
1222 return fallback;
1223 const mime_obj = objc.msgSendId(ut_type, sel_preferred) orelse return fallback;
1224
1225 const mime = try copyNSString(allocator, mime_obj);
1226 allocator.free(fallback);
1227 return mime;
1228}
1229
1230/// The `UTType` array `initForOpeningContentTypes:` takes.
1231///
1232/// Swift's default is `[.item]`, the `UTTypeItem` global. It is built here with
1233/// `+[UTType typeWithIdentifier:@"public.item"]` rather than by `dlsym`-ing the
1234/// `UTType *const` cell: the identifier is documented and stable, and it does
1235/// not depend on the symbol being exported by whatever image loaded the
1236/// framework.
1237///
1238/// Each page-supplied entry is mapped MIME type first and filename extension
1239/// second, as Swift does, and then by identifier — see divergence 4. An entry
1240/// that maps to nothing is refused rather than dropped.
1241///
1242/// `parsePickRequest` refuses an empty `types` array, so the returned array
1243/// always has at least one element.
1244fn buildContentTypes(allocator: std.mem.Allocator, request: PickRequest, UTTypeClass: Id) !Id {
1245 if (!is_darwin) return error.UnsupportedPlatform;
1246
1247 const NSMutableArray = objc.objc_getClass("NSMutableArray") orelse return error.ClassNotFound;
1248 const sel_array = try selector("array");
1249 const sel_add = try selector("addObject:");
1250 const sel_with_identifier = try selector("typeWithIdentifier:");
1251 const sel_with_mime = try selector("typeWithMIMEType:");
1252 const sel_with_extension = try selector("typeWithFilenameExtension:");
1253
1254 const types = objc.msgSendId(NSMutableArray, sel_array) orelse return error.NativeCallFailed;
1255
1256 const listed = request.types orelse {
1257 const ns_item = try objc.createNSString("public.item", allocator);
1258 if (ns_item == null) return error.NativeCallFailed;
1259 const item = objc.msgSendId1(UTTypeClass, sel_with_identifier, ns_item) orelse {
1260 std.log.err("pickFile: UTType has no public.item; cannot build the default", .{});
1261 return BridgeError.NativeCallFailed;
1262 };
1263 objc.msgSendVoid1(types, sel_add, item);
1264 return types;
1265 };
1266
1267 for (listed) |entry| {
1268 const ns_entry = try objc.createNSString(entry, allocator);
1269 if (ns_entry == null) {
1270 std.log.warn("pickFile refused: a types entry is not valid UTF-8", .{});
1271 return BridgeError.InvalidParameter;
1272 }
1273
1274 const mapped =
1275 objc.msgSendId1(UTTypeClass, sel_with_mime, ns_entry) orelse
1276 objc.msgSendId1(UTTypeClass, sel_with_extension, ns_entry) orelse
1277 objc.msgSendId1(UTTypeClass, sel_with_identifier, ns_entry) orelse
1278 {
1279 // Swift's `compactMap` drops this silently and can end up with
1280 // an empty array; dropping a field the page sent is exactly
1281 // what must not happen.
1282 std.log.warn(
1283 "pickFile refused: types entry '{s}' is not a MIME type, a filename " ++
1284 "extension, or a type identifier",
1285 .{entry},
1286 );
1287 return BridgeError.InvalidParameter;
1288 };
1289
1290 // `-[NSMutableArray addObject:]` raises NSInvalidArgumentException on
1291 // nil, which from Zig is an uncatchable crash rather than something the
1292 // page can be told about. `mapped` is non-null by construction above.
1293 objc.msgSendVoid1(types, sel_add, mapped);
1294 }
1295
1296 return types;
1297}
1298
1299// =============================================================================
1300// The Documents directory and the write. Both are the third copy of a chain
1301// `bridge_mobile_db.zig` and `bridge_mobile_locrecording.zig` already carry
1302// file-privately; extracting the shared helper is a change to those two files
1303// and is not made here.
1304// =============================================================================
1305
1306/// `NSDocumentDirectory`.
1307const ns_document_directory: c_ulong = 9;
1308/// `NSUserDomainMask`.
1309const ns_user_domain_mask: c_ulong = 1;
1310/// `NSUTF8StringEncoding`.
1311const ns_utf8_string_encoding: c_ulong = 4;
1312/// `NSDataWritingAtomic`.
1313const ns_data_writing_atomic: c_ulong = 1;
1314
1315/// `NSFileManager` -> `URLsForDirectory:inDomains:` -> `firstObject` -> `path`,
1316/// the chain Swift's `saveFile` runs, with every step guarded.
1317///
1318/// Swift subscripts with `[0]`, which raises `NSRangeException` on an empty
1319/// result; `firstObject` answers nil, and nil here is a named refusal rather
1320/// than an uncatchable SIGABRT.
1321fn documentsPath(allocator: std.mem.Allocator) ![]u8 {
1322 if (!is_darwin) return error.UnsupportedPlatform;
1323
1324 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound;
1325 const sel_default = try selector("defaultManager");
1326 const manager = objc.msgSendId(NSFileManager, sel_default) orelse return error.NativeCallFailed;
1327
1328 const sel_urls = try selector("URLsForDirectory:inDomains:");
1329 // NSUInteger arguments: explicit `c_ulong`, because the variadic msgSend
1330 // cast takes its argument types from what is passed.
1331 const urls = objc.msgSendId2(
1332 manager,
1333 sel_urls,
1334 ns_document_directory,
1335 ns_user_domain_mask,
1336 ) orelse return error.NativeCallFailed;
1337
1338 const sel_first = try selector("firstObject");
1339 const url = objc.msgSendId(urls, sel_first) orelse return error.NotFound;
1340
1341 const sel_path = try selector("path");
1342 const path_obj = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed;
1343
1344 return copyNSString(allocator, path_obj);
1345}
1346
1347/// Write `bytes` to `path`, atomically.
1348///
1349/// Swift's two branches differ — the base64 one uses `Data.write(to:)` with
1350/// default options (non-atomic) and the text one
1351/// `String.write(to:atomically:true,encoding:.utf8)`. Both are atomic here; the
1352/// difference is observable only in that a crash mid-write leaves the previous
1353/// file intact rather than a truncated one, which is the direction worth being
1354/// wrong in.
1355///
1356/// `writeToFile:options:error:` rather than `writeToFile:atomically:`, purely so
1357/// the `NSError` can be logged: the page's reply carries a `BridgeError` enum
1358/// and the description has nowhere else to go.
1359fn writeFileAtomically(allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
1360 if (!is_darwin) return error.UnsupportedPlatform;
1361
1362 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
1363 const sel_with_bytes = try selector("dataWithBytes:length:");
1364 const sel_write = try selector("writeToFile:options:error:");
1365
1366 const data_obj = objc.msgSendId2(
1367 NSData,
1368 sel_with_bytes,
1369 bytes.ptr,
1370 @as(c_ulong, bytes.len),
1371 ) orelse return error.NativeCallFailed;
1372
1373 const ns_path = try objc.createNSString(path, allocator);
1374 if (ns_path == null) return error.NativeCallFailed;
1375
1376 var write_error: Id = null;
1377 const WriteFn = *const fn (Id, Id, Id, c_ulong, *Id) callconv(.c) bool;
1378 const write_fn: WriteFn = @ptrCast(&objc.objc_msgSend);
1379 const wrote = write_fn(data_obj, sel_write, ns_path, ns_data_writing_atomic, &write_error);
1380
1381 if (!wrote) {
1382 std.log.warn(
1383 "saveFile: could not write '{s}': {s}",
1384 .{ path, readNSString(write_error, "localizedDescription") orelse "(no description)" },
1385 );
1386 return BridgeError.NativeCallFailed;
1387 }
1388}
1389
1390/// An `NSString` as owned bytes, refusing a value `UTF8String` truncated.
1391///
1392/// `-[NSString UTF8String]` is NUL-terminated, so a name or path carrying an
1393/// embedded U+0000 would read back short — and a short path names a *different*
1394/// file. The C-string length is therefore checked against
1395/// `lengthOfBytesUsingEncoding:`, the same check
1396/// `bridge_mobile_locrecording.zig` and `bridge_mobile_notifications.zig` make.
1397fn copyNSString(allocator: std.mem.Allocator, ns_string: Id) ![]u8 {
1398 if (!is_darwin) return error.UnsupportedPlatform;
1399
1400 const cstr = objc.getNSStringUTF8(ns_string) orelse return error.NativeCallFailed;
1401 const bytes = std.mem.span(cstr);
1402
1403 const sel_len = try selector("lengthOfBytesUsingEncoding:");
1404 const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong;
1405 const len_fn: LenFn = @ptrCast(&objc.objc_msgSend);
1406 const declared = len_fn(ns_string, sel_len, ns_utf8_string_encoding);
1407
1408 if (declared != bytes.len) {
1409 std.log.warn(
1410 "file picker: a string is {d} UTF-8 bytes but reads back as {d}; refusing rather " ++
1411 "than reporting a truncated name or path",
1412 .{ declared, bytes.len },
1413 );
1414 return error.NativeCallFailed;
1415 }
1416
1417 return allocator.dupe(u8, bytes);
1418}
1419
1420/// A zero-argument `NSString`-returning property, as borrowed bytes. Null for a
1421/// nil object, a nil string, or a selector that will not register — all three
1422/// are "nothing to report". The slice borrows the string's buffer, valid for the
1423/// current autorelease pool; the only caller logs it synchronously.
1424fn readNSString(object: Id, comptime name: [*:0]const u8) ?[]const u8 {
1425 if (!is_darwin) return null;
1426 if (object == null) return null;
1427
1428 const sel = objc.sel_registerName(name) orelse return null;
1429 const value = objc.msgSendId(object, sel) orelse return null;
1430 const utf8 = objc.getNSStringUTF8(value) orelse return null;
1431 return std.mem.span(utf8);
1432}
1433
1434// =============================================================================
1435// Tests - host-only.
1436//
1437// Everything that decides page-visible bytes is pure and pinned here: routing
1438// in both directions, both payloads field by field, the resolve/reject decision
1439// for all four outcomes, the four-key success object and the bare-string save
1440// reply, the base64 and filename guards, and the concurrent-pick refusal.
1441//
1442// Nothing here presents a picker or touches the Documents directory. On a macOS
1443// runner `objc_getClass("UIDocumentPickerViewController")` is null — UIKit is
1444// not loadable there at all — so the UIKit paths are unreachable rather than
1445// merely skipped, and the fixture cannot drive a modal either (it posts raw
1446// envelopes with no way to tap Cancel or choose a file). `saveFile` is the half
1447// that a simulator fixture can drive end to end.
1448// =============================================================================
1449
1450const testing = std.testing;
1451
1452test "the declared actions are the ones the handler serves" {
1453 try testing.expectEqual(@as(usize, 2), capability_actions.len);
1454 try testing.expectEqualStrings(A.pick_file, capability_actions[0].name);
1455 try testing.expectEqualStrings(A.save_file, capability_actions[1].name);
1456
1457 for (capability_actions) |decl| {
1458 // A `.result` whose handler never replies parks the caller until its
1459 // timeout; a `.none` that is awaited resolves immediately and means
1460 // nothing. Swift resolves or rejects both of these.
1461 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1462 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1463 // `.live` with a reason would be a contradiction the manifest shows apps.
1464 try testing.expect(decl.reason == null);
1465 }
1466}
1467
1468test "the action names match the Swift case labels exactly" {
1469 try testing.expectEqualStrings("pickFile", A.pick_file);
1470 try testing.expectEqualStrings("saveFile", A.save_file);
1471}
1472
1473test "every declared action is one the dispatcher routes" {
1474 for (capability_actions) |decl| {
1475 if (routeFor(decl.name) == null) {
1476 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1477 return error.DeclaredActionDoesNotRoute;
1478 }
1479 }
1480}
1481
1482test "every route the dispatcher has is a declared action" {
1483 // The other direction: a route with a handler and no declaration is an
1484 // action the page can call and the manifest denies exists. Each declaration
1485 // must claim a *distinct* route — counting alone would let two rows share
1486 // one route while another went undeclared.
1487 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
1488 for (capability_actions) |decl| {
1489 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1490 const slot = @backingInt(route);
1491 if (claimed[slot]) {
1492 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1493 return error.TwoDeclarationsShareARoute;
1494 }
1495 claimed[slot] = true;
1496 }
1497 for (claimed, 0..) |taken, slot| {
1498 if (!taken) {
1499 std.debug.print(
1500 "route {s} has a handler but no capability_actions row\n",
1501 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
1502 );
1503 return error.RouteNotDeclared;
1504 }
1505 }
1506}
1507
1508test "the dispatcher reaches both handlers, not just the routing table" {
1509 // The other half of table-versus-dispatch: `routeFor` agreeing with
1510 // `capability_actions` proves nothing if `handleMessage` never calls the
1511 // handler. Both payloads fail early and for their own reason — `saveFile`
1512 // has no `data` field, and `pickFile` finds no UIKit picker class on a
1513 // macOS host — so this is a routing check and not a device test. What it
1514 // rules out is the one silent failure: `UnknownAction`, which
1515 // `ios_dispatch.route` reads as "not mine" and hands to the Swift shim.
1516 var bridge = FilePickerBridge.init(testing.allocator);
1517 defer bridge.deinit();
1518
1519 for (capability_actions) |decl| {
1520 bridge.handleMessage(decl.name, "{}") catch |err| {
1521 try testing.expect(err != BridgeError.UnknownAction);
1522 continue;
1523 };
1524 // Neither can succeed here: `saveFile` is missing its required fields
1525 // and `pickFile` has no picker to present.
1526 std.debug.print("'{s}' unexpectedly succeeded on a host\n", .{decl.name});
1527 return error.HandlerSucceededWithoutADevice;
1528 }
1529}
1530
1531test "an action the namespace does not serve is reported, not ignored" {
1532 var bridge = FilePickerBridge.init(testing.allocator);
1533 defer bridge.deinit();
1534
1535 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("noSuchAction", "{}"));
1536
1537 // Casing is how a real typo arrives, and a miss does not fail loudly:
1538 // `ios_dispatch` reads UnknownAction as "not mine" and hands the action to
1539 // the Swift shim, so a typo would silently un-serve the action.
1540 for ([_][]const u8{ "pickfile", "PickFile", "savefile", "SaveFile" }) |typo| {
1541 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(typo, "{}"));
1542 }
1543
1544 // The desktop dialog namespace has its own `saveFile`; it is a different
1545 // action reached through a different `t`, and nothing about it belongs here.
1546 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("showSaveDialog", "{}"));
1547}
1548
1549test "downloadFile is left with the shim, and left undeclared" {
1550 var bridge = FilePickerBridge.init(testing.allocator);
1551 defer bridge.deinit();
1552
1553 for (deliberately_unserved) |name| {
1554 // `UnknownAction` is the one return `ios_dispatch.route` turns into a
1555 // Swift-shim hand-off. Anything else would take the action away from
1556 // the arm that answers it today.
1557 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(name, "{}"));
1558
1559 for (capability_actions) |decl| {
1560 try testing.expect(!std.mem.eql(u8, decl.name, name));
1561 }
1562 try testing.expect(routeFor(name) == null);
1563 }
1564}
1565
1566// -----------------------------------------------------------------------------
1567// pickFile payload.
1568// -----------------------------------------------------------------------------
1569
1570test "pickFile with no types asks for the default single item type" {
1571 // `body[\"types\"] as? [String]` is nil, and Swift's `allowedTypes` stays
1572 // `[.item]`. Null must behave the same as absent, because that is what the
1573 // cast does with a JSON null.
1574 for ([_][]const u8{ "{}", "{\"types\":null}" }) |payload| {
1575 const request = try parsePickRequest(testing.allocator, payload);
1576 defer request.deinit(testing.allocator);
1577 try testing.expect(request.types == null);
1578 }
1579}
1580
1581test "pickFile carries every types entry the page sent, in order" {
1582 const request = try parsePickRequest(
1583 testing.allocator,
1584 "{\"types\":[\"image/png\",\"pdf\",\"public.image\"]}",
1585 );
1586 defer request.deinit(testing.allocator);
1587
1588 const listed = request.types orelse return error.TypesWereDropped;
1589 try testing.expectEqual(@as(usize, 3), listed.len);
1590 try testing.expectEqualStrings("image/png", listed[0]);
1591 try testing.expectEqualStrings("pdf", listed[1]);
1592 try testing.expectEqualStrings("public.image", listed[2]);
1593}
1594
1595test "pickFile refuses a types field it cannot use rather than dropping it" {
1596 // An empty array is what Swift's compactMap can produce, and what
1597 // `initForOpeningContentTypes:` does with one is unverified. A non-array
1598 // and a non-string entry are payloads no page should send, and silently
1599 // treating any of them as the default would widen the types the app asked
1600 // to restrict.
1601 for ([_][]const u8{
1602 "{\"types\":[]}",
1603 "{\"types\":\"image/png\"}",
1604 "{\"types\":42}",
1605 "{\"types\":[\"image/png\",7]}",
1606 "{\"types\":[null]}",
1607 }) |payload| {
1608 try testing.expectError(
1609 BridgeError.InvalidParameter,
1610 parsePickRequest(testing.allocator, payload),
1611 );
1612 }
1613}
1614
1615test "pickFile refuses a payload that is not a JSON object" {
1616 for ([_][]const u8{ "[]", "\"types\"", "not json" }) |payload| {
1617 try testing.expectError(
1618 BridgeError.InvalidJSON,
1619 parsePickRequest(testing.allocator, payload),
1620 );
1621 }
1622}
1623
1624// -----------------------------------------------------------------------------
1625// saveFile payload.
1626// -----------------------------------------------------------------------------
1627
1628/// Parse `json` and hand its root object to `parseSaveRequest`. The request
1629/// borrows the parsed value, so the caller frees them together.
1630fn saveRequestFrom(json: []const u8) !struct {
1631 parsed: std.json.Parsed(std.json.Value),
1632 request: SaveRequest,
1633
1634 fn deinit(self: *@This()) void {
1635 self.parsed.deinit();
1636 }
1637} {
1638 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1639 errdefer parsed.deinit();
1640 return .{ .parsed = parsed, .request = try parseSaveRequest(parsed.value.object) };
1641}
1642
1643test "saveFile reads data and filename, and nothing else" {
1644 var got = try saveRequestFrom(
1645 "{\"data\":\"hello\",\"filename\":\"notes.txt\",\"mimeType\":\"text/plain\"}",
1646 );
1647 defer got.deinit();
1648
1649 try testing.expectEqualStrings("hello", got.request.data);
1650 try testing.expectEqualStrings("notes.txt", got.request.filename);
1651 // `mimeType` is declared by `craft.d.ts` and passed by the README, and read
1652 // by neither Swift nor Android. Accepting and ignoring it is the only shape
1653 // that neither invents a field nor breaks the documented call.
1654}
1655
1656test "saveFile refuses a payload missing either required field" {
1657 const parsed_a = try std.json.parseFromSlice(
1658 std.json.Value,
1659 testing.allocator,
1660 "{\"filename\":\"a.txt\"}",
1661 .{},
1662 );
1663 defer parsed_a.deinit();
1664 try testing.expectError(BridgeError.MissingData, parseSaveRequest(parsed_a.value.object));
1665
1666 const parsed_b = try std.json.parseFromSlice(
1667 std.json.Value,
1668 testing.allocator,
1669 "{\"data\":\"hello\"}",
1670 .{},
1671 );
1672 defer parsed_b.deinit();
1673 try testing.expectError(BridgeError.MissingData, parseSaveRequest(parsed_b.value.object));
1674}
1675
1676test "saveFile refuses a data or filename of the wrong type" {
1677 for ([_][]const u8{
1678 "{\"data\":7,\"filename\":\"a.txt\"}",
1679 "{\"data\":\"hello\",\"filename\":7}",
1680 "{\"data\":null,\"filename\":\"a.txt\"}",
1681 }) |payload| {
1682 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
1683 defer parsed.deinit();
1684 try testing.expectError(
1685 BridgeError.InvalidParameter,
1686 parseSaveRequest(parsed.value.object),
1687 );
1688 }
1689}
1690
1691test "a filename that would leave the Documents directory is refused" {
1692 // Swift joins this string on with appendingPathComponent and the write
1693 // lands wherever it points.
1694 for ([_][]const u8{
1695 "",
1696 "..",
1697 ".",
1698 "../Library/Preferences/x.plist",
1699 "sub/dir.txt",
1700 "/etc/passwd",
1701 "a\x00b.txt",
1702 }) |name| {
1703 try testing.expectError(BridgeError.InvalidParameter, validateFilename(name));
1704 }
1705}
1706
1707test "an ordinary filename with dots in it is not refused" {
1708 // The traversal check must not be a substring match on "..": a name with no
1709 // separator cannot name a parent directory whatever dots it contains.
1710 for ([_][]const u8{ "notes.txt", "my..notes.txt", "..hidden", "archive.tar.gz", "a" }) |name| {
1711 try validateFilename(name);
1712 }
1713}
1714
1715// -----------------------------------------------------------------------------
1716// saveFile content decoding.
1717// -----------------------------------------------------------------------------
1718
1719test "a plain string is written as its own bytes" {
1720 const bytes = try decodeSaveBytes(testing.allocator, "hello, world");
1721 defer testing.allocator.free(bytes);
1722 try testing.expectEqualStrings("hello, world", bytes);
1723}
1724
1725test "a base64 data URL is decoded to the bytes it encodes" {
1726 const bytes = try decodeSaveBytes(testing.allocator, "data:text/plain;base64,aGVsbG8=");
1727 defer testing.allocator.free(bytes);
1728 try testing.expectEqualStrings("hello", bytes);
1729}
1730
1731test "an empty base64 body decodes to an empty file, as Swift writes one" {
1732 const bytes = try decodeSaveBytes(testing.allocator, "data:,");
1733 defer testing.allocator.free(bytes);
1734 try testing.expectEqual(@as(usize, 0), bytes.len);
1735}
1736
1737test "a data URL that cannot be decoded is refused, never reported as written" {
1738 // Swift reaches `resolveCallback(callbackId, result: fileURL.path)` for
1739 // every one of these having written nothing: the `if` body just never runs
1740 // and nothing throws. `data:text/plain,hello` is the documented reproducer.
1741 for ([_][]const u8{
1742 "data:text/plain,hello",
1743 "data:",
1744 "data:text/plain;base64",
1745 "data:a,b,c",
1746 "data:text/plain;base64,aGVsbG8",
1747 "data:text/plain;base64,!!!!",
1748 }) |payload| {
1749 try testing.expectError(
1750 BridgeError.InvalidParameter,
1751 decodeSaveBytes(testing.allocator, payload),
1752 );
1753 }
1754}
1755
1756test "a string that merely contains data: is not treated as a data URL" {
1757 // `hasPrefix`, not `contains`.
1758 const bytes = try decodeSaveBytes(testing.allocator, "see data:text/plain,hello");
1759 defer testing.allocator.free(bytes);
1760 try testing.expectEqualStrings("see data:text/plain,hello", bytes);
1761}
1762
1763// -----------------------------------------------------------------------------
1764// Reply shaping.
1765// -----------------------------------------------------------------------------
1766
1767test "a picked file is the four-key object Swift resolves" {
1768 const file = PickedFile{
1769 .name = @constCast("report.pdf"),
1770 .path = @constCast("/private/var/mobile/report.pdf"),
1771 .mime_type = @constCast("application/pdf"),
1772 .base64 = @constCast("aGVsbG8="),
1773 };
1774
1775 const json = try shapePickedFile(testing.allocator, file);
1776 defer testing.allocator.free(json);
1777
1778 try testing.expectEqualStrings(
1779 "{\"name\":\"report.pdf\",\"path\":\"/private/var/mobile/report.pdf\"," ++
1780 "\"data\":\"data:application/pdf;base64,aGVsbG8=\",\"mimeType\":\"application/pdf\"}",
1781 json,
1782 );
1783
1784 // And it is valid JSON with the keys the page reads, not just string-equal.
1785 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1786 defer parsed.deinit();
1787 try testing.expectEqualStrings("report.pdf", parsed.value.object.get("name").?.string);
1788 try testing.expectEqualStrings(
1789 "data:application/pdf;base64,aGVsbG8=",
1790 parsed.value.object.get("data").?.string,
1791 );
1792}
1793
1794test "a filename carrying quotes and backslashes stays valid JSON" {
1795 // Filenames are user controlled and legitimately contain both. An
1796 // unescaped quote would close the string early and turn the whole reply
1797 // script into a syntax error in the page, with nothing to point at.
1798 const file = PickedFile{
1799 .name = @constCast("we\"ird\\name.txt"),
1800 .path = @constCast("/tmp/we\"ird\\name.txt"),
1801 .mime_type = @constCast("text/plain"),
1802 .base64 = @constCast("QQ=="),
1803 };
1804
1805 const json = try shapePickedFile(testing.allocator, file);
1806 defer testing.allocator.free(json);
1807
1808 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1809 defer parsed.deinit();
1810 try testing.expectEqualStrings("we\"ird\\name.txt", parsed.value.object.get("name").?.string);
1811 try testing.expectEqualStrings("/tmp/we\"ird\\name.txt", parsed.value.object.get("path").?.string);
1812}
1813
1814test "an encoded body outside the base64 alphabet is refused, not emitted raw" {
1815 // The body is appended without escaping because the standard alphabet needs
1816 // none. This is the check that makes that claim true rather than assumed.
1817 const file = PickedFile{
1818 .name = @constCast("a.txt"),
1819 .path = @constCast("/tmp/a.txt"),
1820 .mime_type = @constCast("text/plain"),
1821 .base64 = @constCast("abc\"def"),
1822 };
1823 try testing.expectError(
1824 BridgeError.NativeCallFailed,
1825 shapePickedFile(testing.allocator, file),
1826 );
1827}
1828
1829test "the saved path goes back as a bare JSON string, not an object" {
1830 // `resolveCallback` serialises with .fragmentsAllowed and Swift resolves
1831 // `fileURL.path`. An object here would be a shape nothing reads.
1832 const json = try shapeSavedPath(testing.allocator, "/var/mobile/Documents/notes.txt");
1833 defer testing.allocator.free(json);
1834
1835 try testing.expectEqualStrings("\"/var/mobile/Documents/notes.txt\"", json);
1836
1837 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1838 defer parsed.deinit();
1839 try testing.expect(parsed.value == .string);
1840
1841 // And it is truthy: `craft-bridge.js` settles with `payload || {}`, so an
1842 // empty reply would arrive as `{}`. A container path is never empty.
1843 try testing.expect(parsed.value.string.len > 0);
1844}
1845
1846test "a path needing escaping survives the bare-string reply" {
1847 const json = try shapeSavedPath(testing.allocator, "/tmp/we\"ird\\path.txt");
1848 defer testing.allocator.free(json);
1849
1850 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1851 defer parsed.deinit();
1852 try testing.expectEqualStrings("/tmp/we\"ird\\path.txt", parsed.value.string);
1853}
1854
1855// -----------------------------------------------------------------------------
1856// How a callback settles the call.
1857// -----------------------------------------------------------------------------
1858
1859test "a cancel is a rejection, and only a read that succeeded resolves" {
1860 // The single most likely way this file could go wrong, and the one thing
1861 // that cannot be read off a picker in a test: Swift's
1862 // `documentPickerWasCancelled:` calls `rejectCallback(…, \"Cancelled\")`,
1863 // not a resolve, and an empty `urls` array is its `\"No file selected\"`
1864 // reject.
1865 try testing.expectEqual(Answer.resolve, answerFor(.picked));
1866 try testing.expectEqual(Answer.reject, answerFor(.cancelled));
1867 try testing.expectEqual(Answer.reject, answerFor(.no_selection));
1868 // Divergence 2: Swift resolves `{name, path}` here, without the `data` its
1869 // own type declares required.
1870 try testing.expectEqual(Answer.reject, answerFor(.unreadable));
1871 // The swipe-down belt. Nothing was picked, so a resolve would be success
1872 // for a pick that did not happen.
1873 try testing.expectEqual(Answer.reject, answerFor(.dismissed));
1874
1875 // And every outcome is covered: a new one added without a row above would
1876 // otherwise be pinned by nothing.
1877 try testing.expectEqual(@as(usize, 5), std.enums.values(Outcome).len);
1878}
1879
1880test "the swipe-down belt is registered, and only the answer paths reject" {
1881 // Without `presentationControllerDidDismiss:` on the delegate, a sheet the
1882 // user swipes away leaves `pending_pick` set: the call is never answered
1883 // *and* every later pickFile is refused for the life of the process. Two
1884 // sibling picker modules carry the same belt.
1885 try testing.expectEqualStrings("presentationControllerDidDismiss:", sel_did_dismiss);
1886 // `- (void)m:(id)presentationController` — one object, not two.
1887 try testing.expectEqualStrings("v@:@", ios_delegate.enc.void_one_object);
1888}
1889
1890test "the two delegate selectors are the plural pair, with the encodings UIKit calls" {
1891 // A wrong encoding still registers and still dispatches, then reads
1892 // arguments from the wrong registers with no crash and no compile error.
1893 // Naming the shapes is the only defence available, so the pairing is pinned.
1894 try testing.expectEqualStrings("documentPicker:didPickDocumentsAtURLs:", sel_did_pick_documents);
1895 try testing.expectEqualStrings("documentPickerWasCancelled:", sel_was_cancelled);
1896 try testing.expectEqualStrings("v@:@@", ios_delegate.enc.void_two_objects);
1897 try testing.expectEqualStrings("v@:@", ios_delegate.enc.void_one_object);
1898
1899 // And the deprecated singular is not one of them: registering it would give
1900 // two answers to one pick on any OS that still called it.
1901 try testing.expect(!std.mem.eql(u8, sel_did_pick_documents, "documentPicker:didPickDocumentAtURL:"));
1902}
1903
1904test "the delegate class name is this module's own" {
1905 // Both this and any desktop registration look a class up with
1906 // `objc_getClass` first, so a shared name would let whichever registered
1907 // second silently adopt the other's IMPs.
1908 try testing.expectEqualStrings("CraftIOSDocumentPickerDelegate", delegate_class_name);
1909}
1910
1911// -----------------------------------------------------------------------------
1912// One pending pick.
1913// -----------------------------------------------------------------------------
1914
1915test "a second pick while one is presented is refused, never a silent replacement" {
1916 // Swift overwrites its single `pendingCallbackId` and the first caller's
1917 // promise never settles. The refusal is what makes every caller settle.
1918 try testing.expect(takePendingPick() == null);
1919
1920 const first = ios_async.acquire(A.pick_file) orelse return error.PoolUnexpectedlyFull;
1921 defer ios_async.abandon(first);
1922 try testing.expect(publishPendingPick(first));
1923
1924 const second = ios_async.acquire(A.pick_file) orelse return error.PoolUnexpectedlyFull;
1925 defer ios_async.abandon(second);
1926 try testing.expect(!publishPendingPick(second));
1927
1928 // The first ticket is still the one a callback would answer — not the
1929 // second, and not nothing.
1930 const held = takePendingPick() orelse return error.PendingPickWasLost;
1931 try testing.expectEqual(first.index, held.index);
1932 try testing.expectEqual(first.generation, held.generation);
1933
1934 // With the slot free, the next pick takes it.
1935 try testing.expect(publishPendingPick(second));
1936 try testing.expect(takePendingPick() != null);
1937 try testing.expect(takePendingPick() == null);
1938}
1939
1940test "a callback with nothing pending is ignored rather than replying twice" {
1941 // A late fire, or a duplicate: `takePendingPick` cleared the slot on the
1942 // first one, so the second finds nothing. The belt is the ordinary case
1943 // here — UIKit can send `presentationControllerDidDismiss:` after a Cancel
1944 // that already answered, and it must be a no-op, not a second reply.
1945 try testing.expect(takePendingPick() == null);
1946 settlePick(.cancelled, null);
1947 settlePick(.dismissed, null);
1948 settlePick(.picked, "{}");
1949 try testing.expect(takePendingPick() == null);
1950}
packages/zig/src/bridge_mobile_imagepicker.zigadded+1689-0
Changes to packages/zig/src/bridge_mobile_imagepicker.zig
@@ -0,0 +1,1689 @@
1//! `openCamera` and `pickImage` — the `mobile` namespace's two photo actions.
2//!
3//! They are one picker. Swift serves both from a single
4//! `UIImagePickerController` and a single delegate pair on `Coordinator`
5//! (`CraftApp.swift:376` declares `UIImagePickerControllerDelegate,
6//! UINavigationControllerDelegate`), and they differ in exactly one property:
7//! `sourceType`. The two delegate methods, the success shape, the cancel
8//! shape and the dismissal are shared. So this is one module, one delegate
9//! class, one instance, one pending ticket — and `Source` is both the action
10//! selector and the `UIImagePickerControllerSourceType` raw value.
11//!
12//! ## What crosses the bridge
13//!
14//! **Nothing.** Both injected methods are declared `function()` with zero
15//! parameters (`CraftApp.swift:1574-1590`), and the v1 surface drops its
16//! options on the floor too — `craft.camera.takePicture` is
17//! `function() { return legacyOpenCamera().then(normalizePhoto); }` at
18//! `:2343`, so the `CameraOptions` typed in
19//! `packages/typescript/src/api/mobile.ts:517` never reaches native on iOS.
20//! The Swift dispatcher reads nothing out of `body` either (`:554-563`).
21//! `data` is therefore accepted and ignored, on the
22//! `bridge_mobile_location.getCurrentPosition` precedent: parsing it would
23//! invent a failure mode the shim does not have. There is no field here to
24//! drop.
25//!
26//! ## What goes back
27//!
28//! **Success is a five-key object**, `CraftApp.swift:2647-2654`:
29//!
30//! ```json
31//! {"base64":"…","uri":"data:image/jpeg;base64,…","width":3024,"height":4032,"mimeType":"image/jpeg"}
32//! ```
33//!
34//! `base64` is the raw encoding and `uri` is the same bytes again behind a
35//! `data:` prefix. The duplication is the contract — `normalizePhoto`
36//! (`:2417`) passes an object straight through onto `PhotoResult`
37//! (`mobile.ts:533-544`), which names all five keys — so it is reproduced
38//! rather than tidied. `mimeType` is the literal `"image/jpeg"` for both
39//! actions always, because the image is re-encoded to JPEG whatever the user
40//! picked. `width`/`height` are `-[UIImage size]` in **points**, and
41//! `JSONSerialization` renders an integral double with no fraction, which is
42//! what `{d}` does too.
43//!
44//! `test-bridges.html:513` reads the result as a string (`image?.length`) and
45//! has therefore printed `Base64 length: 0` against Swift for as long as this
46//! dictionary has existed. That is a pre-existing bug in the test page, not a
47//! reason to reply with a bare string: a string would break `craft.camera.*`
48//! and the SDK, which are written for the object.
49//!
50//! **Cancel is a rejection**, not a resolve and not a distinct result
51//! (`:2661-2665` — `rejectCallback(pendingCallbackId, error: "Cancelled")`).
52//! It has to be: `craft-bridge.js:70` resolves with `payload || {}`, so a
53//! `null` or `false` result would arrive at the page as `{}` and the app's
54//! `catch` would never run.
55//!
56//! ## The one thing this module cannot say
57//!
58//! Swift's cancel carries `code: "CRAFT_ERROR"` and `message: "Cancelled"`.
59//! `ios_async.deliverError` hardcodes `bridge_error.BridgeError.NativeCallFailed`
60//! (`ios_async.zig:258-265`), so the rejection the page receives here says
61//! `NATIVE_CALL_FAILED` / `"Native API call failed"`. The promise still
62//! rejects and the app's `catch` still runs — that half of the contract is
63//! kept — but the stated cause is wrong, and this comment is the only place
64//! that is allowed to say otherwise.
65//!
66//! Calling `bridge_error.sendErrorToJS` from the delegate callback instead is
67//! not an option: it ends in `evaluateJavaScript`, and by then the request id
68//! is gone anyway. The fix is a foundation change —
69//! `ios_async.deliverErrorCode(ticket, err)`, one extra field on `Slot` set
70//! under the same lock — and the document and contact pickers need the
71//! identical thing (`documentPickerWasCancelled` `:4912`,
72//! `contactPickerDidCancel` `:5102` both say `"Cancelled"`). It is recorded
73//! here rather than worked around.
74//!
75//! ## The config gate, and exactly what it stands in for
76//!
77//! Swift wraps both actions in `if config.enableCamera`, which defaults to
78//! **false** (`CraftApp.swift:192`, `packages/ios/src/index.ts:120`). When it
79//! is false the `case` falls off the end and replies *nothing at all*, while
80//! `CraftSwiftShim.handleAction` still returns `true` (`:5247`) so Zig
81//! believes the hand-off succeeded. The injected promise is the hand-built
82//! kind with no timeout. **In a default-config app, `openCamera` and
83//! `pickImage` hang the page forever today.** The "working Swift shim" is
84//! only working for an app that set `enableCamera: true`.
85//!
86//! `enableCamera` has no Zig-reachable channel, so this module gates on the
87//! Info.plist keys `packages/ios/src/index.ts:185-186` writes:
88//!
89//! - **`NSCameraUsageDescription` is an extra gate on `openCamera` only, and
90//! it is a crash guard rather than a config proxy.** Presenting a `.camera` picker in a
91//! process without that key is a TCC termination, not an error anything
92//! could be told about. It is written for
93//! `enableCamera || enableVideoRecording || enableQRScanner || enableAR`.
94//! - **`NSPhotoLibraryUsageDescription` stands in for `config.enableCamera`,
95//! and it is over-broad by `enableVideoRecording`** — it is written for
96//! `enableCamera || enableVideoRecording`, which is the closest proxy
97//! available. It is emphatically **not** a photo-library permission gate:
98//! `UIImagePickerController` with `.photoLibrary` has run out-of-process
99//! since iOS 11 and neither prompts nor requires the key. Refusing on it is
100//! a statement about how the app was configured, never about what the
101//! system would allow.
102//!
103//! So `openCamera` requires *both* keys and `pickImage` requires only the
104//! photo-library one.
105//!
106//! Measured against the shim, that trades three ways. An `enableCamera: true`
107//! app behaves exactly as it does now. A default-config app gets an explicit
108//! `PERMISSION_DENIED` instead of a promise that never settles — strictly
109//! better. And exactly one configuration, `enableVideoRecording: true` with
110//! `enableCamera: false`, gains a capability Swift denies it; that one is a
111//! real widening, it runs in the direction the Info.plist already sanctions
112//! (the usage strings exist, so the user sees a prompt the app declared), and
113//! the cell it widens is otherwise an infinite hang. The alternative was to
114//! leave both actions out of `A` entirely and keep the hang, which is a trade
115//! rather than a free choice.
116//!
117//! `.unavailable` was never on the table: a declared-`.unavailable` action
118//! dispatches and refuses, which would take the working `enableCamera: true`
119//! path away from the shim. `bridge_mobile_misc.zig`'s "Why unserved rather
120//! than `.unavailable`" is the precedent.
121//!
122//! ## Simulator
123//!
124//! `+[UIImagePickerController isSourceTypeAvailable:]` answers `NO` for
125//! `.camera` on every simulator. Presenting anyway shows nothing and the
126//! delegate never fires — a promise that hangs, the single worst outcome — so
127//! `openCamera` asks first and refuses synchronously, before any slot is
128//! leased, exactly as Swift does at `:2616-2619`. `pickImage` has no such
129//! guard in Swift and gets none here: `.photoLibrary` is available on
130//! simulator and device alike, and an empty library still presents a picker
131//! whose only move is Cancel.
132//!
133//! ## One picker, one caller
134//!
135//! A modal serves exactly one call, so there is one `pending` for the module
136//! rather than one per action — both actions present the same kind of sheet
137//! and only one can be up. A second request while one is presented is refused
138//! **for the second caller**, with the first left alone. That is the opposite
139//! of `bridge_mobile_location.getCurrentPosition`, which rejects the
140//! *displaced first* call: there `requestLocation` genuinely cancels the
141//! earlier request so it can never be answered, while here the first picker is
142//! still on screen and will answer. Replacing it would strand its promise
143//! forever.
144//!
145//! The cost of refusing rather than displacing is that a `pending` which is
146//! never taken refuses every later call for the life of the process. Three
147//! callbacks can take it — the pick, the cancel, and
148//! `presentationControllerDidDismiss:` for the swipe-down that has been widely
149//! reported not to fire `imagePickerControllerDidCancel:` since iOS 13. That
150//! third one is a belt, not a proof: if a presentation controller cannot be
151//! reached the module says so in the log and does **not** claim that a cancel
152//! is always delivered.
153//!
154//! ## A Swift bug this deliberately does not inherit
155//!
156//! `startVideoRecording` (`:3731-3749`) reuses the *same* `Coordinator`
157//! delegate and the *same* `pendingCallbackId`, sets `mediaTypes` to
158//! `["public.movie"]`, and then `didFinishPickingMediaWithInfo` reads
159//! `info[.originalImage]` — nil for a movie — so every finished recording
160//! rejects with `"Failed to process image"`. It stays with the shim; because
161//! this module has its own delegate class and its own instance, the crosstalk
162//! disappears rather than getting worse.
163
164const std = ;
165const builtin = ;
166const capabilities = ;
167const bridge_error = ;
168const objc_runtime = ;
169const ios_async = ;
170const ios_delegate = ;
171const bridge_mobile_system = ;
172const compat_mutex = ;
173
174const objc = objc_runtime.objc;
175const is_darwin = builtin.target.os.tag.isDarwin();
176
177/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
178/// location/motion precedent: `objc_runtime.objc` is an empty struct off
179/// Darwin and a `callconv(.c)` fn-pointer *type* is analysed even when a
180/// comptime platform guard prunes the body around it. A single optional
181/// pointer, never `?objc.id`: a double optional is illegal in `callconv(.c)`.
182const Id = ?*anyopaque;
183
184/// The action names, spelled exactly as the Swift `case` labels spell them.
185/// `test/ios_conformance_test.zig` matches this block against those labels in
186/// both directions and fails the build if two modules declare one name.
187pub const A = struct {
188 pub const open_camera = "openCamera";
189 pub const pick_image = "pickImage";
190};
191
192/// `.result`: each Swift path terminates in one `resolveCallback` (the pick)
193/// or one `rejectCallback` (the cancel, or an image that would not encode),
194/// and the page's promise is the hand-built kind with no timeout — `.none`
195/// here would park a caller forever rather than for thirty seconds.
196///
197/// `.live`: it dispatches and presents. A refusal is a specific failure — the
198/// app was not built with the camera enabled, no camera on this device, the
199/// picker is already up — never the action's normal answer, which is what
200/// `.unavailable` would declare.
201pub const capability_actions = [_]capabilities.ActionDecl{
202 .{ .name = A.open_camera, .reply = .result },
203 .{ .name = A.pick_image, .reply = .result },
204};
205
206/// Which picker an action selects — and, because the values are chosen to be,
207/// the `UIImagePickerControllerSourceType` to set.
208///
209/// `NSInteger`, hence `c_long`: a narrower type passes 0 and 1 correctly by
210/// luck and breaks the day the enum grows.
211/// `UIImagePickerControllerSourceTypeSavedPhotosAlbum = 2` is deliberately
212/// absent — no action posts it.
213const Source = enum(c_long) {
214 photo_library = 0,
215 camera = 1,
216
217 fn actionName(self: Source) []const u8 {
218 return switch (self) {
219 .camera => A.open_camera,
220 .photo_library => A.pick_image,
221 };
222 }
223};
224
225/// Which picker an action selects, split out from `handleMessage` so the
226/// table-versus-dispatch agreement is assertable on a host that has no UIKit.
227fn routeFor(action: []const u8) ?Source {
228 if (std.mem.eql(u8, action, A.open_camera)) return .camera;
229 if (std.mem.eql(u8, action, A.pick_image)) return .photo_library;
230 return null;
231}
232
233/// `image.jpegData(compressionQuality: 0.8)` — Swift's spelling, `:2646`.
234/// Always JPEG, always this quality, for both actions.
235const jpeg_compression_quality: f64 = 0.8;
236
237pub const ImagePickerBridge = struct {
238 allocator: std.mem.Allocator,
239
240 const Self = @This();
241
242 pub fn init(allocator: std.mem.Allocator) Self {
243 return .{ .allocator = allocator };
244 }
245
246 pub fn deinit(_: *Self) void {}
247
248 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
249 const source = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
250 return self.present(source, data);
251 }
252
253 /// Configure a `UIImagePickerController` for `source`, present it, and let
254 /// the delegate answer when the user is done.
255 ///
256 /// `data` is accepted and ignored — see the module comment; neither JS
257 /// surface sends a field and the Swift dispatcher reads none.
258 ///
259 /// Every fallible step runs *before* `ios_async.acquire`: the config gate,
260 /// the class, the camera-availability question, all five delegate
261 /// selectors, both `dlsym`s, the delegate class and instance, the
262 /// presenter, the picker itself and every selector used to present it.
263 /// `bridge_mobile_location.zig` states the rule this follows: no error path
264 /// may exist between leasing a slot and handing the request to the
265 /// framework, because a missed `abandon` permanently narrows the pool. The
266 /// one exception below — `publish` answering `.busy` — releases the lease
267 /// by hand on the way out, and is the only path that has to.
268 fn present(self: *Self, source: Source, data: []const u8) !void {
269 _ = self;
270 _ = data;
271 if (!is_darwin) return error.UnsupportedPlatform;
272
273 const action = source.actionName();
274
275 // The config gate first. It is the only refusal that touches no UIKit
276 // at all, and on a host build it is what keeps everything below from
277 // running against a runtime that has no picker in it.
278 try requireConfigured(source);
279
280 // A cheap refusal before any of the expensive work: a modal is on
281 // screen, so nothing built here could be presented anyway. `publish`
282 // below is the authoritative one — it decides under the lock, so a
283 // request that slipped past this read still cannot displace the first.
284 if (busyWith()) |holder| return busyRefusal(action, holder);
285
286 const PickerClass = objc.objc_getClass("UIImagePickerController") orelse {
287 std.log.warn(
288 "{s} refused: this process has no UIImagePickerController class, " ++
289 "so UIKit is not loaded here",
290 .{action},
291 );
292 return bridge_error.BridgeError.PlatformNotSupported;
293 };
294
295 // Swift's `guard UIImagePickerController.isSourceTypeAvailable(.camera)`.
296 // Asked, never assumed: on a simulator the answer is NO, and presenting
297 // anyway shows nothing and never calls the delegate back.
298 if (source == .camera) {
299 const sel_available = try selector("isSourceTypeAvailable:");
300 const AvailableFn = *const fn (Id, Id, c_long) callconv(.c) bool;
301 const available: AvailableFn = @ptrCast(&objc.objc_msgSend);
302 if (!available(PickerClass, sel_available, @backingInt(Source.camera))) {
303 std.log.warn(
304 "openCamera refused: no camera source is available on this device " ++
305 "(every simulator answers NO)",
306 .{},
307 );
308 return bridge_error.BridgeError.PlatformNotSupported;
309 }
310 }
311
312 // Everything the delegate will need to shape a reply, resolved while an
313 // error is still deliverable to the caller.
314 const work = try Work.resolve();
315
316 const delegate = try ensureDelegate();
317 const presenter = try bridge_mobile_system.uikit.topmostViewController();
318
319 const sel_set_source = try selector("setSourceType:");
320 const sel_set_delegate = try selector("setDelegate:");
321 const sel_present = try selector("presentViewController:animated:completion:");
322
323 const picker = (try objc.allocInit(PickerClass)) orelse return error.NativeCallFailed;
324 // +1 from alloc/init. The presenting controller retains the picker for
325 // the life of the presentation, so this reference is ours to drop —
326 // and on every error path below it is the only one there is. Same
327 // shape as `bridge_mobile_system.share`.
328 defer objc.release(picker);
329
330 const SetSourceFn = *const fn (Id, Id, c_long) callconv(.c) void;
331 const set_source: SetSourceFn = @ptrCast(&objc.objc_msgSend);
332 set_source(picker, sel_set_source, @backingInt(source));
333
334 // `mediaTypes` and `allowsEditing` are deliberately left alone: Swift
335 // sets neither, so the defaults (`["public.image"]` and `NO`) are the
336 // contract, and `.originalImage` is the right info key precisely
337 // because editing is off.
338 objc.msgSendVoid1(picker, sel_set_delegate, delegate);
339
340 // If UIKit adapts this presentation to a popover — which it can on a
341 // regular size class, i.e. any iPad and iPhone in some multitasking
342 // configurations — it raises `NSGenericException` when neither
343 // `sourceView` nor `barButtonItem` is set, and an Objective-C exception
344 // is an uncatchable crash from Zig. `anchorPopover` reads
345 // `popoverPresentationController` and returns immediately when it is
346 // nil, which Apple documents it to be unless the modal presentation
347 // style is `.popover`. So this either anchors a popover correctly or
348 // does nothing at all; which of the two happens is UIKit's decision and
349 // is not predicted here.
350 try bridge_mobile_system.uikit.anchorPopover(picker, presenter);
351
352 var dismiss_observed = attachDismissObserver(picker, delegate, sel_set_delegate);
353
354 const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void;
355 const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend);
356
357 const ticket = ios_async.acquire(action) orelse return poolFull(action);
358
359 // Published before the framework call, never after: presentation is
360 // asynchronous but a delegate callback that reached an empty slot would
361 // have no ticket to answer with.
362 switch (publish(.{ .ticket = ticket, .source = source, .work = work })) {
363 .published => {},
364 .busy => |holder| {
365 // Lost a race with another dispatch. The lease is released by
366 // hand here because this is the one error path that exists
367 // after `acquire`; leaving it would narrow the pool forever.
368 ios_async.abandon(ticket);
369 return busyRefusal(action, holder);
370 },
371 }
372
373 present_fn(presenter, sel_present, picker, true, null);
374
375 // A presentation controller may only exist once the presentation has
376 // begun. Retried here rather than claimed above, because the interactive
377 // -dismissal callback is the only thing that can free `pending` when a
378 // sheet is swiped away and `imagePickerControllerDidCancel:` does not
379 // fire.
380 if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, sel_set_delegate);
381 if (!dismiss_observed) {
382 std.log.info(
383 "{s}: no presentation controller to observe; a swipe-down dismissal " ++
384 "may leave this call unanswered",
385 .{action},
386 );
387 }
388 }
389};
390
391/// The answer for a full slot pool, copied from `bridge_mobile_location`:
392/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
393/// designated stand-in, and the point is that the caller gets an explicit
394/// rejection instead of a promise that never settles.
395fn poolFull(action: []const u8) bridge_error.BridgeError {
396 std.log.warn(
397 "{s} refused: all {d} async slots in flight",
398 .{ action, ios_async.max_in_flight },
399 );
400 return bridge_error.BridgeError.InvalidParameter;
401}
402
403/// The answer for a second request while a picker is already on screen.
404///
405/// The *second* caller is refused and the first is left alone, because the
406/// first picker is still up and will answer. Same `INVALID_PARAMETER`
407/// stand-in, for the same reason: `BridgeError` cannot say "busy", and silence
408/// is not an option.
409fn busyRefusal(action: []const u8, holder: Source) bridge_error.BridgeError {
410 std.log.warn(
411 "{s} refused: {s} already has a picker presented; refusing the second call rather " ++
412 "than replacing the first, whose promise would then never settle",
413 .{ action, holder.actionName() },
414 );
415 return bridge_error.BridgeError.InvalidParameter;
416}
417
418// =============================================================================
419// Reply shaping. Pure — no Objective-C — so the exact bytes the page receives
420// are pinned by host tests on every platform.
421// =============================================================================
422
423/// One picked image, read into plain values.
424///
425/// `base64` is the raw encoding, owned by whoever built it. `width`/`height`
426/// are `-[UIImage size]` in points, which is what Swift puts on the wire.
427const Photo = struct {
428 base64: []const u8,
429 width: f64,
430 height: f64,
431};
432
433/// The five-key object Swift's `didFinishPickingMediaWithInfo` resolves.
434///
435/// Key order is fixed at base64, uri, width, height, mimeType. Swift's is a
436/// `Dictionary` and therefore arbitrary; a caller cannot depend on it, but a
437/// test can only pin bytes that are deterministic, so one order is chosen and
438/// held.
439///
440/// The encoded payload is appended **twice** — once raw, once behind the
441/// `data:` prefix — because that is what `PhotoResult` is written for. It is
442/// the single largest cost in this path and it is not this module's to change.
443fn shapePhoto(allocator: std.mem.Allocator, photo: Photo) ![]u8 {
444 var out: std.ArrayListUnmanaged(u8) = .empty;
445 errdefer out.deinit(allocator);
446
447 // Not escaped, and that is checked rather than asserted: the standard
448 // base64 alphabet plus its pad character contains no byte JSON must
449 // escape. See "the encoded payload needs no JSON escaping" below, which
450 // fails if the encoder is ever swapped for one whose alphabet does.
451 try out.appendSlice(allocator, "{\"base64\":\"");
452 try out.appendSlice(allocator, photo.base64);
453 try out.appendSlice(allocator, "\",\"uri\":\"");
454 try out.appendSlice(allocator, data_uri_prefix);
455 try out.appendSlice(allocator, photo.base64);
456 try out.appendSlice(allocator, "\",\"width\":");
457 try appendNumber(allocator, &out, photo.width);
458 try out.appendSlice(allocator, ",\"height\":");
459 try appendNumber(allocator, &out, photo.height);
460 try out.appendSlice(allocator, ",\"mimeType\":\"");
461 try out.appendSlice(allocator, mime_type);
462 try out.appendSlice(allocator, "\"}");
463
464 return out.toOwnedSlice(allocator);
465}
466
467/// Swift's `"data:image/jpeg;base64," + base64`.
468const data_uri_prefix = "data:image/jpeg;base64,";
469
470/// The literal Swift sends for both actions, always — the image is re-encoded
471/// to JPEG, so a PNG or HEIC chosen from the library still arrives as this.
472const mime_type = "image/jpeg";
473
474/// One `f64` as a JSON number.
475///
476/// `{d}` renders decimal notation, never scientific, and prints an integral
477/// value with no fraction — `3024`, matching what `JSONSerialization` writes
478/// for the same `CGFloat`. A finite `f64` can need up to
479/// `std.fmt.float.bufferSize(.decimal, f64)` (347) bytes, so the buffer is
480/// sized from the formatter rather than guessed.
481///
482/// Non-finite is refused rather than printed: `inf` and `nan` are not JSON,
483/// and `JSONSerialization` refuses them too. A refusal reaches the page as a
484/// rejection; printing them would produce a syntax error inside the reply
485/// script with nothing to point at.
486fn appendNumber(
487 allocator: std.mem.Allocator,
488 out: *std.ArrayListUnmanaged(u8),
489 value: f64,
490) !void {
491 if (!std.math.isFinite(value)) return bridge_error.BridgeError.InvalidParameter;
492 var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
493 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
494}
495
496/// What a delegate callback decided to send.
497///
498/// Deciding and sending are separate so the decision is checkable without a
499/// picker: getting `.reject` and `.photo` the wrong way round is the difference
500/// between an app's `catch` running and its `then` running on an image that
501/// does not exist.
502const Answer = union(enum) {
503 /// The five-key object, ready to hand to `ios_async.deliverJson`.
504 photo: []const u8,
505 /// A rejection. Every failure *and* every cancel takes this arm.
506 reject,
507};
508
509/// The two ways this module answers a captured call, and the only two.
510///
511/// Neither replies directly — `evaluateJavaScript` is main-thread-only and
512/// these run on a framework callback — so both go through `ios_async`, which
513/// hops to the main queue under the request id captured back at dispatch.
514fn settle(call: Pending, answer: Answer) void {
515 switch (answer) {
516 .photo => |json| ios_async.deliverJson(call.ticket, json),
517 // A Cancel is not a native failure — the call worked and the answer
518 // is "no" — so it carries the code that says so.
519 .reject => ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Cancelled),
520 }
521}
522
523/// Swift's `if let image = …, let imageData = … { resolve } else { reject }`.
524/// A reply that could not be built is a rejection, never an object with an
525/// empty payload and zeroed dimensions — `normalizePhoto` would hand the app
526/// exactly that and its `then` would run.
527fn finishAnswer(json: ?[]const u8) Answer {
528 return if (json) |bytes| .{ .photo = bytes } else .reject;
529}
530
531/// A cancel is a **rejection**. Swift is
532/// `rejectCallback(pendingCallbackId, error: "Cancelled")`, and
533/// `craft-bridge.js` resolves with `payload || {}`, so any resolved shape —
534/// `null`, `false`, `{}` — would run the app's `then` branch for a picture the
535/// user declined to take.
536const cancel_answer: Answer = .reject;
537
538// =============================================================================
539// The config gate. See the module comment for what each key does and does not
540// stand for.
541// =============================================================================
542
543/// Written by `packages/ios/src/index.ts:185` for
544/// `enableCamera || enableVideoRecording || enableQRScanner || enableAR`.
545/// Required before a `.camera` picker may be presented at all.
546const key_camera_usage = "NSCameraUsageDescription";
547
548/// Written by `packages/ios/src/index.ts:186` for
549/// `enableCamera || enableVideoRecording`. Used here as the closest available
550/// proxy for `config.enableCamera` — **not** as a permission gate; a
551/// `.photoLibrary` picker needs no such key on iOS 11+.
552const key_photo_library_usage = "NSPhotoLibraryUsageDescription";
553
554fn requireConfigured(source: Source) !void {
555 if (!is_darwin) return error.UnsupportedPlatform;
556
557 // Checked first, and only for the camera: this one is a crash guard, not a
558 // configuration question. Presenting a `.camera` picker in a process
559 // without the key is a TCC termination — there is no error to map and
560 // nothing left to tell the page.
561 if (source == .camera and !try infoPlistHas(key_camera_usage)) {
562 std.log.warn(
563 "openCamera refused: Info.plist has no {s}, and presenting a camera picker " ++
564 "without it terminates the process rather than failing",
565 .{key_camera_usage},
566 );
567 return bridge_error.BridgeError.PermissionDenied;
568 }
569
570 if (!try infoPlistHas(key_photo_library_usage)) {
571 std.log.warn(
572 "{s} refused: Info.plist has no {s}, the closest available proxy for " ++
573 "config.enableCamera, so this app was not built with the camera enabled",
574 .{ source.actionName(), key_photo_library_usage },
575 );
576 return bridge_error.BridgeError.PermissionDenied;
577 }
578}
579
580/// Whether the main bundle's Info.plist carries `key`.
581///
582/// Errors rather than answering `false` when the runtime itself will not
583/// cooperate: "there is no NSBundle class" and "this app did not enable the
584/// camera" are different facts, and collapsing them would blame the app's
585/// configuration for a broken process. Every runtime result is guarded.
586fn infoPlistHas(comptime key: [*:0]const u8) !bool {
587 if (!is_darwin) return error.UnsupportedPlatform;
588
589 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
590 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
591 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
592
593 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
594 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
595 return error.SelectorNotFound;
596 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
597
598 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
599 return error.SelectorNotFound;
600 return objc.msgSendId1(bundle, sel_lookup, ns_key) != null;
601}
602
603// =============================================================================
604// Everything the delegate needs, resolved while an error is still deliverable.
605// =============================================================================
606
607fn selector(name: [*:0]const u8) !Id {
608 if (!is_darwin) return error.UnsupportedPlatform;
609 return objc.sel_registerName(name) orelse error.SelectorNotFound;
610}
611
612/// The five selectors a delegate callback sends.
613///
614/// Resolved at dispatch for the reason `bridge_mobile_location.Sels` states: a
615/// `sel_registerName` failure inside a callback could only be logged, or worse
616/// turned into a rejection for a picture the user actually took.
617const Sels = struct {
618 object_for_key: Id,
619 size: Id,
620 bytes: Id,
621 length: Id,
622 /// `-[UIViewController dismissViewControllerAnimated:completion:]`, sent to
623 /// the picker; UIKit forwards it to the presenter. Both Swift callbacks
624 /// call it, and missing either one leaves the app behind a modal forever.
625 dismiss: Id,
626
627 fn resolve() !Sels {
628 if (!is_darwin) return error.UnsupportedPlatform;
629 return .{
630 .object_for_key = try selector("objectForKey:"),
631 .size = try selector("size"),
632 .bytes = try selector("bytes"),
633 .length = try selector("length"),
634 .dismiss = try selector("dismissViewControllerAnimated:completion:"),
635 };
636 }
637};
638
639/// `NSData *UIImageJPEGRepresentation(UIImage *, CGFloat)`.
640///
641/// `CGFloat` is `double` on every 64-bit Apple platform. This is a plain C
642/// function with no Objective-C equivalent — `-jpegData(compressionQuality:)`
643/// is Swift-only sugar over it.
644const JpegFn = *const fn (Id, f64) callconv(.c) Id;
645
646/// Everything a delegate callback needs, captured for the call it will answer.
647const Work = struct {
648 sels: Sels,
649 /// The value of `UIImagePickerControllerOriginalImage`, the info key Swift
650 /// reads. Resolved before presenting, so a UIKit that does not carry it is
651 /// a synchronous refusal rather than a picker nobody can read the result of.
652 original_image_key: Id,
653 jpeg: JpegFn,
654
655 fn resolve() !Work {
656 if (!is_darwin) return error.UnsupportedPlatform;
657
658 // `dlsym` rather than `extern "c"`: the host test binaries link
659 // Cocoa/WebKit/CoreGraphics and not UIKit, and `refAllDecls` forces
660 // analysis of every declaration, so an `extern "c" fn
661 // UIImageJPEGRepresentation` would be an undefined symbol at host link
662 // time. The same route `bridge_mobile_location.bestAccuracy` takes for
663 // `kCLLocationAccuracyBest`.
664 const jpeg_symbol = dlsym(RTLD_DEFAULT, "UIImageJPEGRepresentation") orelse {
665 std.log.warn(
666 "image picker refused: UIImageJPEGRepresentation is not in this process, " ++
667 "so a picked image could not be encoded",
668 .{},
669 );
670 return bridge_error.BridgeError.PlatformNotSupported;
671 };
672
673 // `UIImagePickerControllerOriginalImage` is an `extern NSString * const`
674 // in UIKit. Read through the symbol rather than rebuilt from its own
675 // spelling: the constant's *value* happening to equal its name is
676 // undocumented, and a null answer here is the honest "UIKit is not
677 // here" rather than a plausible-looking string that matches nothing.
678 const key_symbol = dlsym(RTLD_DEFAULT, "UIImagePickerControllerOriginalImage") orelse {
679 std.log.warn(
680 "image picker refused: UIImagePickerControllerOriginalImage is not in this " ++
681 "process, so a pick could not be read",
682 .{},
683 );
684 return bridge_error.BridgeError.PlatformNotSupported;
685 };
686 const key_cell: *const Id = @ptrCast(@alignCast(key_symbol));
687 const original_image_key = key_cell.* orelse return error.NativeCallFailed;
688
689 return .{
690 .sels = try Sels.resolve(),
691 .original_image_key = original_image_key,
692 // `@alignCast` because `dlsym` answers with a `*anyopaque`, whose
693 // alignment is 1; a function pointer's is 4. The assertion is sound —
694 // this address is a function entry point in a loaded image.
695 .jpeg = @ptrCast(@alignCast(jpeg_symbol)),
696 };
697 }
698};
699
700extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
701
702/// dyld's "search every image" pseudo-handle, `(void *)-2` on Darwin.
703const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
704
705// =============================================================================
706// The delegate class, built at runtime.
707// =============================================================================
708
709/// Deliberately namespaced, on the `CraftIOSLocationDelegate` precedent: a
710/// generic name risks colliding with a desktop class in the same host test
711/// binary, and both sides look their class up with `objc_getClass` first — so
712/// whichever registered second would silently adopt the other's IMPs.
713const delegate_class_name = "CraftIOSImagePickerDelegate";
714
715/// The delegate instance, held for the life of the process.
716///
717/// `UIImagePickerController` holds its `delegate` **weakly**, and
718/// `ios_delegate.instantiate` returns +1 while retaining nothing. This var is
719/// the strong reference; without it the first Cancel is a use-after-free.
720var delegate_instance: Id = null;
721
722/// The method table, so the encodings are assertable on a host.
723///
724/// A wrong encoding string still registers and still dispatches, then reads
725/// arguments out of the wrong registers with no crash and no compile error —
726/// which is why `ios_delegate.enc` names the shapes rather than spelling them.
727///
728/// A function rather than a `const`: `@ptrCast` from a function pointer to
729/// `*const anyopaque` is a runtime cast, and a container-level initialiser
730/// would ask the compiler to perform it at comptime.
731///
732/// The three IMPs are plain `fn … callconv(.c)` rather than `export fn`. What
733/// `class_addMethod` needs is the C calling convention and the `(self, _cmd, …)`
734/// parameter order, both of which a plain function has; an exported symbol
735/// would additionally be visible to the linker, which is how the desktop and
736/// iOS location delegates collided once already.
737fn delegateMethods() [3]ios_delegate.Method {
738 return .{
739 // - (void)imagePickerController:(UIImagePickerController *)picker
740 // didFinishPickingMediaWithInfo:(NSDictionary *)info
741 .{
742 .selector = "imagePickerController:didFinishPickingMediaWithInfo:",
743 .imp = @ptrCast(&craftIOSImagePickerDidFinish),
744 .types = ios_delegate.enc.void_two_objects,
745 },
746 // - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker
747 .{
748 .selector = "imagePickerControllerDidCancel:",
749 .imp = @ptrCast(&craftIOSImagePickerDidCancel),
750 .types = ios_delegate.enc.void_one_object,
751 },
752 // - (void)presentationControllerDidDismiss:(UIPresentationController *)pc
753 // The swipe-down belt. iOS 13+; on anything older it is simply never sent.
754 .{
755 .selector = "presentationControllerDidDismiss:",
756 .imp = @ptrCast(&craftIOSImagePickerDidDismiss),
757 .types = ios_delegate.enc.void_one_object,
758 },
759 };
760}
761
762/// Register the delegate class once, and keep one instance alive.
763///
764/// `UIImagePickerControllerDelegate`, `UINavigationControllerDelegate` and
765/// `UIAdaptivePresentationControllerDelegate` are deliberately not declared:
766/// UIKit dispatches through `respondsToSelector:`, so conformance is not
767/// required, and Swift implements none of the navigation-delegate methods it
768/// declares at `:376` either.
769fn ensureDelegate() !Id {
770 if (!is_darwin) return error.UnsupportedPlatform;
771 if (delegate_instance) |existing| return existing;
772
773 const methods = delegateMethods();
774 const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &methods);
775 const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed;
776 delegate_instance = instance;
777 return instance;
778}
779
780/// Make the picker's presentation controller report an interactive dismissal
781/// to us. Returns whether it was attached.
782///
783/// A `.photoLibrary` picker has presented as a sheet since iOS 13, and a
784/// swipe-down is widely reported not to call
785/// `imagePickerControllerDidCancel:`. That report is not verified anywhere in
786/// this repo, so this is a belt and not a fix: it costs two messages, and if
787/// the report is right it is the only thing that frees `pending` after a
788/// swipe. This module refuses later calls while one is pending, so a stranded
789/// ticket is worse here than in Swift — it would disable the picker for the
790/// life of the process.
791///
792/// Idempotent against the real cancel: whichever callback runs first takes
793/// `pending` under the lock, so the second finds nothing, and `ios_async`'s
794/// generation counter would make a second delivery a no-op anyway.
795fn attachDismissObserver(picker: Id, delegate: Id, sel_set_delegate: Id) bool {
796 if (!is_darwin) return false;
797
798 const sel_presentation = objc.sel_registerName("presentationController") orelse return false;
799 const presentation = objc.msgSendId(picker, sel_presentation) orelse return false;
800 objc.msgSendVoid1(presentation, sel_set_delegate, delegate);
801 return true;
802}
803
804// =============================================================================
805// One pending call for the whole module, because one modal serves one caller.
806// =============================================================================
807
808const Pending = struct {
809 ticket: ios_async.Ticket,
810 source: Source,
811 work: Work,
812};
813
814var pending: ?Pending = null;
815
816/// Guards `pending`. Held even though UIKit delivers these callbacks on the
817/// main thread, because "it should always be the main thread" is not a guard
818/// and the dispatch side is not provably main-thread for every entry point.
819var state_mutex: compat_mutex.Mutex = .{};
820
821/// `.busy` carries who is holding the picker, so the refusal can name it.
822const PublishResult = union(enum) { published, busy: Source };
823
824/// Record the call the delegate will answer, refusing rather than replacing.
825///
826/// The opposite of `bridge_mobile_location.publishPendingFix`, deliberately:
827/// there the framework cancels the earlier request, so the displaced caller
828/// can never be answered and must be rejected. Here the first picker is still
829/// on screen and will answer, so replacing it would strand a promise that was
830/// about to settle.
831fn publish(call: Pending) PublishResult {
832 state_mutex.lock();
833 defer state_mutex.unlock();
834 if (pending) |existing| return .{ .busy = existing.source };
835 pending = call;
836 return .published;
837}
838
839/// Read and clear. Clearing is what makes a second delegate call — a late
840/// fire, or the dismissal belt after a real cancel — a no-op rather than a
841/// second reply, and it is what frees the picker for the next caller.
842fn takePending() ?Pending {
843 state_mutex.lock();
844 defer state_mutex.unlock();
845 const call = pending;
846 pending = null;
847 return call;
848}
849
850/// Which action is holding the picker, or null when it is free.
851fn busyWith() ?Source {
852 state_mutex.lock();
853 defer state_mutex.unlock();
854 return if (pending) |call| call.source else null;
855}
856
857// =============================================================================
858// The delegate methods.
859//
860// None of them replies directly: `evaluateJavaScript` is main-thread-only and
861// these are framework callbacks, so the finished JSON goes to
862// `ios_async.deliverJson` (or `deliverError`), which hops to the main queue and
863// answers under the request id captured back at dispatch.
864// =============================================================================
865
866/// `-imagePickerController:didFinishPickingMediaWithInfo:`
867fn craftIOSImagePickerDidFinish(_: Id, _: Id, picker: Id, info: Id) callconv(.c) void {
868 if (!is_darwin) return;
869
870 const call = takePending();
871
872 // Swift's first line, and unconditional for a reason: a shaping failure
873 // below must still take the modal off the screen. A rejected promise is
874 // recoverable; an app stuck behind a picker it cannot close is not.
875 dismissPresented(picker, if (call) |c| c.work.sels.dismiss else null);
876
877 const answering = call orelse {
878 std.log.info(
879 "image picker: a pick arrived with no call waiting; ignored rather than " ++
880 "answered twice",
881 .{},
882 );
883 return;
884 };
885
886 const allocator = std.heap.c_allocator;
887
888 // Swift's `"Failed to process image"` branch is every failure here: a nil
889 // image, a JPEG encode that returned nothing, a reply that would not shape.
890 const json: ?[]u8 = buildPhotoReply(info, answering.work, allocator) catch |err| shaped: {
891 std.log.err(
892 "{s}: could not build the reply for the picked image ({}); rejecting rather " ++
893 "than replying with an image that was not encoded",
894 .{ answering.source.actionName(), err },
895 );
896 break :shaped null;
897 };
898 defer if (json) |owned| allocator.free(owned);
899
900 settle(answering, finishAnswer(json));
901}
902
903/// Read the pick and shape it, or fail. Split from the callback so the
904/// callback has exactly one reply site.
905fn buildPhotoReply(info: Id, work: Work, allocator: std.mem.Allocator) ![]u8 {
906 const photo = try readPhoto(info, work, allocator);
907 defer allocator.free(photo.base64);
908 return shapePhoto(allocator, photo);
909}
910
911/// `-imagePickerControllerDidCancel:`
912fn craftIOSImagePickerDidCancel(_: Id, _: Id, picker: Id) callconv(.c) void {
913 if (!is_darwin) return;
914
915 const call = takePending();
916 dismissPresented(picker, if (call) |c| c.work.sels.dismiss else null);
917
918 const answering = call orelse {
919 std.log.info("image picker: a cancel arrived with no call waiting; ignored", .{});
920 return;
921 };
922
923 // A rejection, per `cancel_answer`. The code it carries is
924 // `NATIVE_CALL_FAILED` and not Swift's `"Cancelled"`, because
925 // `ios_async.deliverError` has no way to name an error — see the module
926 // comment, which is the only place that says so.
927 settle(answering, cancel_answer);
928}
929
930/// `-presentationControllerDidDismiss:` — the swipe-down belt.
931///
932/// Nothing is dismissed here: UIKit has already taken the sheet off the
933/// screen, which is precisely what this callback reports. Sending
934/// `dismissViewControllerAnimated:` now could take down whatever the presenter
935/// shows next instead.
936fn craftIOSImagePickerDidDismiss(_: Id, _: Id, _: Id) callconv(.c) void {
937 if (!is_darwin) return;
938
939 // Empty is the ordinary case: a real cancel or a real pick already took it.
940 const answering = takePending() orelse return;
941
942 std.log.info(
943 "{s}: the picker was dismissed interactively without a delegate callback; " ++
944 "rejecting rather than leaving the call unanswered",
945 .{answering.source.actionName()},
946 );
947 settle(answering, cancel_answer);
948}
949
950/// `[picker dismissViewControllerAnimated:YES completion:nil]`.
951///
952/// `resolved` is the selector captured at dispatch when there is a call to
953/// answer. A stray callback has none, so it registers the selector here — that
954/// is not a reply path, so a failure is loggable rather than something a
955/// caller could be told about either way.
956///
957/// `?Id` is `??*anyopaque`, and *both* nulls have to reach the registration
958/// below. The outer one is "no call was waiting, so nothing was captured"; the
959/// inner one is a captured `Sels` whose `dismiss` is itself null. A single
960/// `orelse` only unwraps the outer one, and would then hand `objc_msgSend` a
961/// nil selector — a crash on a live picker rather than the miss this branch is
962/// written for. So it is flattened first.
963fn dismissPresented(picker: Id, resolved: ?Id) void {
964 if (!is_darwin) return;
965
966 const sel = flattenSelector(resolved) orelse
967 (objc.sel_registerName("dismissViewControllerAnimated:completion:") orelse {
968 std.log.err(
969 "image picker: dismissViewControllerAnimated:completion: would not register; " ++
970 "the picker stays on screen",
971 .{},
972 );
973 return;
974 });
975
976 const DismissFn = *const fn (Id, Id, bool, Id) callconv(.c) void;
977 const dismiss: DismissFn = @ptrCast(&objc.objc_msgSend);
978 dismiss(picker, sel, true, null);
979}
980
981/// Collapse `??*anyopaque` to `?*anyopaque` so one `orelse` covers both nulls.
982///
983/// Split out only so the collapse is assertable on a host — the bug it prevents
984/// (a nil selector reaching `objc_msgSend`) has no observable form short of a
985/// device crash.
986fn flattenSelector(resolved: ?Id) Id {
987 return if (resolved) |inner| inner else null;
988}
989
990/// `CGSize` — two `CGFloat`s, 16 bytes.
991///
992/// Spelled locally rather than named from `objc_runtime`, on the
993/// `bridge_mobile_location.Coord` precedent, so the layout is visible at the
994/// call site. Returned through the regular `objc_msgSend`: `objc_msgSend_stret`
995/// is for structs larger than this and does not exist on arm64 at all.
996const Size = extern struct { width: f64, height: f64 };
997
998/// Turn the picker's info dictionary into the values the reply is built from.
999///
1000/// The returned `base64` is owned by the caller. Every Objective-C result is
1001/// guarded, and each nil is a distinct error rather than a zero: Swift's
1002/// `else { rejectCallback(…, "Failed to process image") }` covers all of them,
1003/// and a plausible-looking empty photo is exactly the fabrication that must not
1004/// happen.
1005///
1006/// The `NSData` is autoreleased and consumed synchronously here — encoded
1007/// before this function returns — so no ownership is taken of it.
1008fn readPhoto(info: Id, work: Work, allocator: std.mem.Allocator) !Photo {
1009 if (!is_darwin) return error.UnsupportedPlatform;
1010 if (info == null) return error.NoPickerInfo;
1011
1012 const ObjectForKeyFn = *const fn (Id, Id, Id) callconv(.c) Id;
1013 const object_for_key: ObjectForKeyFn = @ptrCast(&objc.objc_msgSend);
1014 const image = object_for_key(info, work.sels.object_for_key, work.original_image_key) orelse
1015 return error.NoOriginalImage;
1016
1017 // Read before the encode, so the dimensions describe the same UIImage the
1018 // bytes came from.
1019 const SizeFn = *const fn (Id, Id) callconv(.c) Size;
1020 const size_fn: SizeFn = @ptrCast(&objc.objc_msgSend);
1021 const size = size_fn(image, work.sels.size);
1022
1023 const data = work.jpeg(image, jpeg_compression_quality) orelse return error.JpegEncodingFailed;
1024
1025 const LengthFn = *const fn (Id, Id) callconv(.c) c_ulong;
1026 const length_fn: LengthFn = @ptrCast(&objc.objc_msgSend);
1027 const length: usize = @intCast(length_fn(data, work.sels.length));
1028 if (length == 0) return error.EmptyJpegData;
1029
1030 const BytesFn = *const fn (Id, Id) callconv(.c) ?[*]const u8;
1031 const bytes_fn: BytesFn = @ptrCast(&objc.objc_msgSend);
1032 const bytes = bytes_fn(data, work.sels.bytes) orelse return error.EmptyJpegData;
1033
1034 // Encoded once, in Zig, over the raw buffer. The alternative,
1035 // `-base64EncodedStringWithOptions:`, would allocate a multi-megabyte
1036 // `NSString` that then has to be copied out again; this is the one copy in
1037 // this path that can actually be removed.
1038 const encoder = std.base64.standard.Encoder;
1039 const out = try allocator.alloc(u8, encoder.calcSize(length));
1040 errdefer allocator.free(out);
1041 _ = encoder.encode(out, bytes[0..length]);
1042
1043 return .{ .base64 = out, .width = size.width, .height = size.height };
1044}
1045
1046// =============================================================================
1047// Tests — host-only.
1048//
1049// Everything that decides page-visible bytes is pure and pinned here: routing
1050// in both directions, the five keys and their order, the duplicated payload,
1051// integral dimensions, the cancel-is-a-rejection decision, and the concurrency
1052// policy that is the whole reason a modal needs state at all.
1053//
1054// Nothing here presents a picker or touches UIKit. On a macOS runner
1055// `objc_getClass("UIImagePickerController")` is null and the test binary has no
1056// Info.plist, so the config gate refuses first — which is what makes the file
1057// safe to run. The Objective-C paths that *are* exercised for real are the ones
1058// with no device behind them: selector resolution and delegate registration.
1059// =============================================================================
1060
1061const testing = std.testing;
1062
1063test "the declared actions are the ones the handler serves" {
1064 try testing.expectEqual(@as(usize, 2), capability_actions.len);
1065 try testing.expectEqualStrings(A.open_camera, capability_actions[0].name);
1066 try testing.expectEqualStrings(A.pick_image, capability_actions[1].name);
1067
1068 for (capability_actions) |decl| {
1069 // A `.result` whose handler never replies parks the caller on an untimed
1070 // promise; a `.none` that is awaited resolves immediately and means
1071 // nothing. Swift settles both of these, so both are `.result`.
1072 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1073 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1074 // `.live` with a reason would be a contradiction the manifest shows apps.
1075 try testing.expect(decl.reason == null);
1076 }
1077}
1078
1079test "the action names match the Swift case labels exactly" {
1080 // The conformance ratchet compares these against the `case "…":` labels in
1081 // `CraftApp.swift` in both directions; a prettier spelling would read as
1082 // Zig serving an action the spec does not have.
1083 try testing.expectEqualStrings("openCamera", A.open_camera);
1084 try testing.expectEqualStrings("pickImage", A.pick_image);
1085}
1086
1087test "every declared action is one the dispatcher routes" {
1088 for (capability_actions) |decl| {
1089 if (routeFor(decl.name) == null) {
1090 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1091 return error.DeclaredActionDoesNotRoute;
1092 }
1093 }
1094}
1095
1096test "every route the dispatcher has is a declared action" {
1097 // The other direction: a route with a handler and no declaration is an
1098 // action the page can call and the manifest denies exists. Each declaration
1099 // must claim a *distinct* route — counting alone would let two rows share
1100 // one route while another went undeclared.
1101 const sources = std.enums.values(Source);
1102 var claimed = std.mem.zeroes([std.enums.values(Source).len]bool);
1103
1104 for (capability_actions) |decl| {
1105 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1106 var slot: usize = sources.len;
1107 for (sources, 0..) |candidate, i| {
1108 if (candidate == route) slot = i;
1109 }
1110 if (slot == sources.len) return error.RouteIsNotEnumerated;
1111 if (claimed[slot]) {
1112 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1113 return error.TwoDeclarationsShareARoute;
1114 }
1115 claimed[slot] = true;
1116 }
1117
1118 for (claimed, 0..) |taken, slot| {
1119 if (!taken) {
1120 std.debug.print(
1121 "route {s} has a handler but no capability_actions row\n",
1122 .{@tagName(sources[slot])},
1123 );
1124 return error.RouteNotDeclared;
1125 }
1126 }
1127}
1128
1129test "the two actions differ only in the source type they set" {
1130 // The whole design of this module in two assertions. The raw values are
1131 // UIKit's: PhotoLibrary = 0, Camera = 1, SavedPhotosAlbum = 2 (unused).
1132 try testing.expectEqual(Source.camera, routeFor("openCamera").?);
1133 try testing.expectEqual(Source.photo_library, routeFor("pickImage").?);
1134 try testing.expectEqual(@as(c_long, 1), @backingInt(Source.camera));
1135 try testing.expectEqual(@as(c_long, 0), @backingInt(Source.photo_library));
1136
1137 // And each knows its own name back, because every log line and every
1138 // `ios_async.acquire` is keyed on it — a swap here would correlate a reply
1139 // to the wrong action.
1140 try testing.expectEqualStrings(A.open_camera, Source.camera.actionName());
1141 try testing.expectEqualStrings(A.pick_image, Source.photo_library.actionName());
1142}
1143
1144test "an action the namespace does not serve is reported, not ignored" {
1145 var bridge = ImagePickerBridge.init(testing.allocator);
1146 defer bridge.deinit();
1147
1148 try testing.expectError(
1149 bridge_error.BridgeError.UnknownAction,
1150 bridge.handleMessage("noSuchAction", "{}"),
1151 );
1152
1153 // Casing is how a real typo arrives, and a miss does not fail loudly:
1154 // `ios_dispatch` reads UnknownAction as "not mine" and hands the action to
1155 // the Swift shim, so a typo silently keeps the old implementation.
1156 for ([_][]const u8{ "opencamera", "OpenCamera", "pickimage", "PickImage" }) |typo| {
1157 try testing.expectError(
1158 bridge_error.BridgeError.UnknownAction,
1159 bridge.handleMessage(typo, "{}"),
1160 );
1161 }
1162
1163 // The neighbours that share Swift's Coordinator delegate but are not this
1164 // module's. Two modules answering one action would make `ios_dispatch`'s
1165 // first-match routing order-dependent — and `startVideoRecording` in
1166 // particular must keep reaching the shim, whose delegate crosstalk this
1167 // module exists partly to avoid inheriting.
1168 for ([_][]const u8{
1169 "startVideoRecording",
1170 "stopVideoRecording",
1171 "takeScreenshot",
1172 "share",
1173 }) |action| {
1174 try testing.expectError(
1175 bridge_error.BridgeError.UnknownAction,
1176 bridge.handleMessage(action, "{}"),
1177 );
1178 }
1179}
1180
1181// -----------------------------------------------------------------------------
1182// Reply shaping.
1183// -----------------------------------------------------------------------------
1184
1185/// A JSON number as an `f64`, whatever variant `std.json` chose for it. The
1186/// variant is a property of the *bytes*: `{d}` renders 3024 as the integer
1187/// token `3024` (`.integer`) and 3024.5 as `3024.5` (`.float`), so reading
1188/// `.float` directly would panic on the first.
1189fn numberOf(value: std.json.Value) !f64 {
1190 return switch (value) {
1191 .integer => |i| @floatFromInt(i),
1192 .float => |f| f,
1193 .number_string => |text| try std.fmt.parseFloat(f64, text),
1194 else => error.ReplyFieldIsNotANumber,
1195 };
1196}
1197
1198test "the reply is the five-key object Swift resolves, in a fixed order" {
1199 const json = try shapePhoto(testing.allocator, .{
1200 .base64 = "aGVsbG8=",
1201 .width = 3024,
1202 .height = 4032,
1203 });
1204 defer testing.allocator.free(json);
1205
1206 // The exact bytes, because the shape is the contract and `PhotoResult`
1207 // names all five keys. Integral dimensions carry no fraction: Swift's
1208 // `JSONSerialization` writes `3024` for the same CGFloat, and `{d}` agrees.
1209 try testing.expectEqualStrings(
1210 "{\"base64\":\"aGVsbG8=\"," ++
1211 "\"uri\":\"data:image/jpeg;base64,aGVsbG8=\"," ++
1212 "\"width\":3024,\"height\":4032," ++
1213 "\"mimeType\":\"image/jpeg\"}",
1214 json,
1215 );
1216
1217 // And it really parses as the object the page consumes, rather than merely
1218 // looking like one.
1219 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1220 defer parsed.deinit();
1221 const obj = parsed.value.object;
1222 try testing.expectEqual(@as(usize, 5), obj.count());
1223 try testing.expectEqualStrings("aGVsbG8=", obj.get("base64").?.string);
1224 try testing.expectEqualStrings("image/jpeg", obj.get("mimeType").?.string);
1225 try testing.expectEqual(@as(f64, 3024), try numberOf(obj.get("width").?));
1226 try testing.expectEqual(@as(f64, 4032), try numberOf(obj.get("height").?));
1227}
1228
1229test "the payload is carried twice, raw in base64 and prefixed in uri" {
1230 // The duplication is the contract, not an oversight: `normalizePhoto`
1231 // passes the object straight through and the SDK's `PhotoResult` reads both
1232 // fields. A `uri` that merely repeated `base64` without the prefix, or a
1233 // `base64` that carried the prefix, would each break one consumer.
1234 const payload = "QUJDREVGRw==";
1235 const json = try shapePhoto(testing.allocator, .{
1236 .base64 = payload,
1237 .width = 1,
1238 .height = 1,
1239 });
1240 defer testing.allocator.free(json);
1241
1242 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1243 defer parsed.deinit();
1244
1245 const base64 = parsed.value.object.get("base64").?.string;
1246 const uri = parsed.value.object.get("uri").?.string;
1247
1248 try testing.expectEqualStrings(payload, base64);
1249 try testing.expectEqualStrings(data_uri_prefix ++ payload, uri);
1250 try testing.expect(std.mem.startsWith(u8, uri, "data:image/jpeg;base64,"));
1251 try testing.expect(!std.mem.startsWith(u8, base64, "data:"));
1252}
1253
1254test "the mime type is the JPEG literal for both actions, whatever was picked" {
1255 // The image is re-encoded at `jpeg_compression_quality`, so a PNG or HEIC
1256 // chosen from the library still arrives as JPEG. Sniffing the original
1257 // type and reporting it would be a different, and wrong, contract.
1258 try testing.expectEqualStrings("image/jpeg", mime_type);
1259 try testing.expectEqual(@as(f64, 0.8), jpeg_compression_quality);
1260}
1261
1262test "a fractional dimension keeps its fraction rather than being rounded" {
1263 // `UIImage.size` is in points and is a CGFloat, so a scaled or cropped
1264 // image can be fractional. Truncating would report a size the image does
1265 // not have.
1266 const json = try shapePhoto(testing.allocator, .{
1267 .base64 = "",
1268 .width = 375.5,
1269 .height = 812.25,
1270 });
1271 defer testing.allocator.free(json);
1272
1273 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1274 defer parsed.deinit();
1275 try testing.expectEqual(@as(f64, 375.5), try numberOf(parsed.value.object.get("width").?));
1276 try testing.expectEqual(@as(f64, 812.25), try numberOf(parsed.value.object.get("height").?));
1277}
1278
1279test "a non-finite dimension is refused rather than printed" {
1280 // `inf` and `nan` are not JSON. Printing them would put a bare `inf` inside
1281 // the reply script and turn the whole `__craftBridgeResult` call into a
1282 // syntax error in the page, with nothing to point at.
1283 for ([_]f64{ std.math.inf(f64), -std.math.inf(f64), std.math.nan(f64) }) |bad| {
1284 try testing.expectError(
1285 bridge_error.BridgeError.InvalidParameter,
1286 shapePhoto(testing.allocator, .{ .base64 = "", .width = bad, .height = 1 }),
1287 );
1288 try testing.expectError(
1289 bridge_error.BridgeError.InvalidParameter,
1290 shapePhoto(testing.allocator, .{ .base64 = "", .width = 1, .height = bad }),
1291 );
1292 }
1293}
1294
1295test "the encoded payload needs no JSON escaping" {
1296 // `shapePhoto` appends the payload straight into a JSON string, which is
1297 // only safe because of what the encoder can emit. Checked rather than
1298 // asserted in a comment: if the encoder is ever swapped for one with a
1299 // different alphabet — url-safe uses `-` and `_`, which are also fine, but
1300 // a future one might not be — this fails here rather than on a device with
1301 // a reply the page cannot parse.
1302 const encoder = std.base64.standard.Encoder;
1303 for (std.base64.standard_alphabet_chars) |c| {
1304 try testing.expect(c != '"' and c != '\\' and c >= 0x20);
1305 }
1306 try testing.expect(encoder.pad_char != null);
1307 const pad = encoder.pad_char.?;
1308 try testing.expect(pad != '"' and pad != '\\' and pad >= 0x20);
1309}
1310
1311test "a real encode round-trips into a reply the page can parse" {
1312 // The bytes chosen include 0xFF 0xD8 0xFF, a real JPEG SOI, and a run that
1313 // forces padding — the two things that make an encoder's output visibly
1314 // wrong if the length arithmetic is off.
1315 const raw = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F' };
1316 const encoder = std.base64.standard.Encoder;
1317
1318 const encoded = try testing.allocator.alloc(u8, encoder.calcSize(raw.len));
1319 defer testing.allocator.free(encoded);
1320 _ = encoder.encode(encoded, &raw);
1321
1322 const json = try shapePhoto(testing.allocator, .{
1323 .base64 = encoded,
1324 .width = 10,
1325 .height = 10,
1326 });
1327 defer testing.allocator.free(json);
1328
1329 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1330 defer parsed.deinit();
1331
1332 const decoder = std.base64.standard.Decoder;
1333 const field = parsed.value.object.get("base64").?.string;
1334 const back = try testing.allocator.alloc(u8, try decoder.calcSizeForSlice(field));
1335 defer testing.allocator.free(back);
1336 try decoder.decode(back, field);
1337 try testing.expectEqualSlices(u8, &raw, back);
1338}
1339
1340// -----------------------------------------------------------------------------
1341// Which callback resolves and which rejects.
1342// -----------------------------------------------------------------------------
1343
1344test "a cancel rejects rather than resolving anything" {
1345 // The single most important shape in this file. `craft-bridge.js` resolves
1346 // with `payload || {}`, so a `null`, `false` or `{}` result for a cancel
1347 // would run the app's `then` branch — `normalizePhoto` would even hand it a
1348 // well-formed `PhotoResult` with an empty payload — for a picture the user
1349 // declined to take. Swift rejects; so does this.
1350 try testing.expect(cancel_answer == .reject);
1351 try testing.expect(cancel_answer != .photo);
1352}
1353
1354test "an image that will not encode rejects, and only a complete one resolves" {
1355 // Swift's `if let image = …, let imageData = …` — both halves required,
1356 // either one missing is `"Failed to process image"`. A reply built from a
1357 // missing half would be `{"base64":"","width":0,"height":0,…}`, which is
1358 // indistinguishable from a real photo of nothing, and `normalizePhoto`
1359 // would pass it straight through to the app's `then`.
1360 try testing.expect(finishAnswer(null) == .reject);
1361
1362 const shaped = "{\"base64\":\"\",\"uri\":\"data:image/jpeg;base64,\",\"width\":1,\"height\":1,\"mimeType\":\"image/jpeg\"}";
1363 switch (finishAnswer(shaped)) {
1364 .photo => |json| try testing.expectEqualStrings(shaped, json),
1365 .reject => return error.AShapedReplyWasRejected,
1366 }
1367}
1368
1369// -----------------------------------------------------------------------------
1370// One picker, one caller.
1371// -----------------------------------------------------------------------------
1372
1373/// A `Work` with nothing resolved. Safe in these tests because none of them
1374/// reaches a delegate callback — they exercise the slot, not the picker.
1375const empty_work = Work{
1376 .sels = .{
1377 .object_for_key = null,
1378 .size = null,
1379 .bytes = null,
1380 .length = null,
1381 .dismiss = null,
1382 },
1383 .original_image_key = null,
1384 .jpeg = &noopJpeg,
1385};
1386
1387fn noopJpeg(_: Id, _: f64) callconv(.c) Id {
1388 return null;
1389}
1390
1391test "the slot hands the delegate its ticket once, and clearing it frees the picker" {
1392 _ = takePending();
1393
1394 const ticket = ios_async.Ticket{ .index = 6, .generation = 17 };
1395 try testing.expect(publish(.{ .ticket = ticket, .source = .camera, .work = empty_work }) == .published);
1396
1397 const taken = takePending() orelse return error.PublishedCallWentMissing;
1398 try testing.expectEqual(@as(u5, 6), taken.ticket.index);
1399 try testing.expectEqual(@as(u32, 17), taken.ticket.generation);
1400 try testing.expectEqual(Source.camera, taken.source);
1401
1402 // Taken means gone: a double-firing delegate must not reply twice, and the
1403 // next caller must find the picker free.
1404 try testing.expect(takePending() == null);
1405 try testing.expect(busyWith() == null);
1406}
1407
1408test "a second request while a picker is presented is refused, and the first is untouched" {
1409 // The concurrency policy, and the one place it deliberately differs from
1410 // `bridge_mobile_location`: there the framework cancels the earlier request
1411 // so the displaced caller *must* be rejected; here the first picker is
1412 // still on screen and will answer, so replacing it would strand a promise
1413 // that was about to settle.
1414 _ = takePending();
1415
1416 const first = ios_async.Ticket{ .index = 2, .generation = 3 };
1417 const second = ios_async.Ticket{ .index = 9, .generation = 4 };
1418
1419 try testing.expect(publish(.{ .ticket = first, .source = .camera, .work = empty_work }) == .published);
1420
1421 // The refusal names who is holding it, and it is the *second* caller that
1422 // is refused.
1423 const refused = publish(.{ .ticket = second, .source = .photo_library, .work = empty_work });
1424 switch (refused) {
1425 .published => return error.SecondRequestSilentlyReplacedTheFirst,
1426 .busy => |holder| try testing.expectEqual(Source.camera, holder),
1427 }
1428 try testing.expectEqual(Source.camera, busyWith().?);
1429
1430 // And it is a rejection the caller can see, never silence.
1431 try testing.expectEqual(
1432 bridge_error.BridgeError.InvalidParameter,
1433 busyRefusal(A.pick_image, .camera),
1434 );
1435
1436 // The survivor is the first, unchanged — the whole point.
1437 const survivor = takePending() orelse return error.FirstCallWasDropped;
1438 try testing.expectEqual(@as(u5, 2), survivor.ticket.index);
1439 try testing.expectEqual(@as(u32, 3), survivor.ticket.generation);
1440 try testing.expectEqual(Source.camera, survivor.source);
1441
1442 // With the picker free, the next caller is admitted.
1443 try testing.expect(publish(.{ .ticket = second, .source = .photo_library, .work = empty_work }) == .published);
1444 _ = takePending();
1445}
1446
1447test "a delegate callback with no call recorded touches no slot" {
1448 // A late or duplicate fire must not reach `deliverJson`/`deliverError` at
1449 // all. With the slot empty there is no ticket to reply with, and all three
1450 // methods return without touching the pool — asserted by the slot staying
1451 // empty rather than by observing a reply that must not happen.
1452 _ = takePending();
1453
1454 craftIOSImagePickerDidFinish(null, null, null, null);
1455 try testing.expect(takePending() == null);
1456
1457 craftIOSImagePickerDidCancel(null, null, null);
1458 try testing.expect(takePending() == null);
1459
1460 craftIOSImagePickerDidDismiss(null, null, null);
1461 try testing.expect(takePending() == null);
1462
1463 try testing.expect(busyWith() == null);
1464}
1465
1466test "the pool refusal is an error the caller sees, not a dropped call" {
1467 try testing.expectEqual(
1468 bridge_error.BridgeError.InvalidParameter,
1469 poolFull(A.open_camera),
1470 );
1471}
1472
1473// -----------------------------------------------------------------------------
1474// Platform and gate.
1475// -----------------------------------------------------------------------------
1476
1477test "off Darwin the handler refuses rather than pretending to present" {
1478 if (is_darwin) return error.SkipZigTest;
1479
1480 var bridge = ImagePickerBridge.init(testing.allocator);
1481 defer bridge.deinit();
1482
1483 for ([_][]const u8{ A.open_camera, A.pick_image }) |action| {
1484 try testing.expectError(error.UnsupportedPlatform, bridge.handleMessage(action, "{}"));
1485 }
1486 try testing.expectError(error.UnsupportedPlatform, requireConfigured(.camera));
1487 try testing.expectError(error.UnsupportedPlatform, requireConfigured(.photo_library));
1488 try testing.expectError(error.UnsupportedPlatform, Sels.resolve());
1489 try testing.expectError(error.UnsupportedPlatform, Work.resolve());
1490 try testing.expectError(error.UnsupportedPlatform, ensureDelegate());
1491}
1492
1493test "without the usage descriptions the gate refuses before any picker exists" {
1494 if (!is_darwin) return error.SkipZigTest;
1495
1496 // The host runner is a bare binary with no Info.plist, which is exactly the
1497 // "this app was not built with the camera enabled" case the gate is for. It
1498 // is also what keeps this file safe to run: everything past the gate would
1499 // look for UIKit, and on a runner that had it, present a real picker.
1500 if (requireConfigured(.photo_library)) |_| {
1501 // A runner that does carry the key (tests hosted inside a real app)
1502 // would take the live path below, so skip rather than present.
1503 return error.SkipZigTest;
1504 } else |err| switch (err) {
1505 bridge_error.BridgeError.PermissionDenied => {
1506 var bridge = ImagePickerBridge.init(testing.allocator);
1507 defer bridge.deinit();
1508
1509 for ([_][]const u8{ A.open_camera, A.pick_image }) |action| {
1510 try testing.expectError(
1511 bridge_error.BridgeError.PermissionDenied,
1512 bridge.handleMessage(action, "{}"),
1513 );
1514 }
1515
1516 // The refusal happens before a slot is leased and before anything
1517 // is published: an unreleased lease narrows the pool for every
1518 // later call, and a published call with no picker behind it would
1519 // refuse every request for the life of the process.
1520 try testing.expect(takePending() == null);
1521 try testing.expect(busyWith() == null);
1522 },
1523 else => return err,
1524 }
1525}
1526
1527test "the payload is ignored, not parsed" {
1528 if (!is_darwin) return error.SkipZigTest;
1529 // Real dispatches, so this needs the process the gate stops.
1530 if (requireConfigured(.photo_library)) |_| return error.SkipZigTest else |_| {}
1531
1532 // Both injected methods are declared `function()` and the Swift dispatcher
1533 // reads nothing out of `body`, so a payload that is not even JSON must
1534 // reach exactly the same outcome as `{}`. If it did not, this module would
1535 // have invented a failure the shim does not have.
1536 var bridge = ImagePickerBridge.init(testing.allocator);
1537 defer bridge.deinit();
1538
1539 for ([_][]const u8{ A.open_camera, A.pick_image }) |action| {
1540 const empty = bridge.handleMessage(action, "{}");
1541 const junk = bridge.handleMessage(action, "{not json");
1542 const missing = bridge.handleMessage(action, "");
1543 // The options the SDK types but the page never sends.
1544 const options = bridge.handleMessage(
1545 action,
1546 "{\"quality\":0.5,\"maxWidth\":1024,\"camera\":\"front\",\"allowsEditing\":true}",
1547 );
1548
1549 try testing.expectEqual(empty, junk);
1550 try testing.expectEqual(empty, missing);
1551 try testing.expectEqual(empty, options);
1552
1553 if (empty) |_| {} else |err| {
1554 try testing.expect(err != bridge_error.BridgeError.InvalidJSON);
1555 try testing.expect(err != bridge_error.BridgeError.MissingData);
1556 }
1557 }
1558
1559 try testing.expect(takePending() == null);
1560}
1561
1562test "every selector the delegate needs resolves on a real runtime" {
1563 if (!is_darwin) return error.SkipZigTest;
1564
1565 // `sel_registerName` interns a name whether or not any class implements it,
1566 // so this touches no framework and presents nothing. What it catches is a
1567 // typo in one of the five spellings, which would otherwise surface as a
1568 // rejected pick — or a picker that cannot be dismissed — on a device.
1569 const sels = try Sels.resolve();
1570 inline for (comptime std.meta.fieldNames(Sels)) |name| {
1571 if (@field(sels, name) == null) {
1572 std.debug.print("selector for Sels.{s} did not resolve\n", .{name});
1573 return error.SelectorDidNotResolve;
1574 }
1575 }
1576
1577 // Distinct, or two different reads would come from one property.
1578 try testing.expect(sels.bytes != sels.length);
1579 try testing.expect(sels.size != sels.object_for_key);
1580 try testing.expect(sels.dismiss != sels.size);
1581}
1582
1583test "the delegate class registers under its own name, idempotently" {
1584 if (!is_darwin) return error.SkipZigTest;
1585
1586 // No UIKit is involved: this allocates an NSObject subclass and one
1587 // instance. It is the load-bearing half of the delegate — a class that does
1588 // not register, or that adopts another module's IMPs, is a picker that
1589 // never answers.
1590 const first = try ensureDelegate();
1591 const second = try ensureDelegate();
1592
1593 // The same live instance, not merely two non-null answers: the instance is
1594 // the strong reference `UIImagePickerController`'s weak `delegate` depends
1595 // on, and a second one would leave the first free to be released.
1596 try testing.expectEqual(first, second);
1597
1598 const cls = objc.object_getClass(first) orelse return error.DelegateHasNoClass;
1599 try testing.expectEqualStrings(delegate_class_name, std.mem.span(objc.class_getName(cls)));
1600
1601 // All three methods attached. UIKit dispatches through
1602 // `respondsToSelector:`, so a method that failed to attach is a callback
1603 // that is simply never made — silence, on an untimed promise.
1604 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse
1605 return error.SelectorNotFound;
1606 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
1607 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
1608
1609 for (delegateMethods()) |method| {
1610 const sel = objc.sel_registerName(method.selector.ptr) orelse return error.SelectorNotFound;
1611 if (!responds(first, sel_responds, sel)) {
1612 std.debug.print("the delegate does not respond to {s}\n", .{method.selector});
1613 return error.DelegateMissingMethod;
1614 }
1615 }
1616}
1617
1618test "the delegate methods carry the encodings their signatures actually have" {
1619 // A wrong encoding registers, dispatches, and then reads arguments out of
1620 // the wrong registers: no compile error, often no crash, just garbage
1621 // pointers. Naming them is the only defence, so the pairing is pinned.
1622 const methods = delegateMethods();
1623 try testing.expectEqual(@as(usize, 3), methods.len);
1624
1625 try testing.expectEqualStrings(
1626 "imagePickerController:didFinishPickingMediaWithInfo:",
1627 methods[0].selector,
1628 );
1629 // Two object arguments — the picker and the info dictionary.
1630 try testing.expectEqualStrings("v@:@@", methods[0].types);
1631
1632 try testing.expectEqualStrings(
1633 "imagePickerControllerDidCancel:",
1634 methods[1].selector,
1635 );
1636 // One object argument — the picker.
1637 try testing.expectEqualStrings("v@:@", methods[1].types);
1638
1639 try testing.expectEqualStrings(
1640 "presentationControllerDidDismiss:",
1641 methods[2].selector,
1642 );
1643 try testing.expectEqualStrings("v@:@", methods[2].types);
1644
1645 // Taken from `ios_delegate.enc` rather than spelled here, so a caller
1646 // cannot drift from the shared vocabulary.
1647 try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[0].types);
1648 try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[1].types);
1649 try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[2].types);
1650}
1651
1652test "a captured dismiss selector that is itself null falls through to registration" {
1653 // `dismissPresented` takes `?Id`, which is `??*anyopaque`: the callbacks
1654 // build it as `if (call) |c| c.work.sels.dismiss else null`, so a stray
1655 // callback yields the *outer* null and a call yields `.some(selector)`.
1656 // Nothing stops that inner selector from being null — `empty_work` is
1657 // exactly that shape — and a lone `orelse` would unwrap `.some(null)` into
1658 // a nil selector handed straight to `objc_msgSend`, which is a crash on a
1659 // live picker rather than the missing-selector branch.
1660 const outer_null: ?Id = null;
1661 const inner_null: ?Id = @as(Id, null);
1662
1663 // The two really are distinct values at the type level, which is the whole
1664 // trap: only one of them is `null` to `orelse`.
1665 try testing.expect(outer_null == null);
1666 try testing.expect(inner_null != null);
1667 try testing.expect(inner_null.? == null);
1668
1669 // Flattened, both mean "no selector was captured".
1670 try testing.expect(flattenSelector(outer_null) == null);
1671 try testing.expect(flattenSelector(inner_null) == null);
1672 try testing.expect(flattenSelector(empty_work.sels.dismiss) == null);
1673}
1674
1675test "the delegate class name cannot collide with another module's" {
1676 // `bridge_location.zig` registers `CraftLocationDelegate` for the desktop
1677 // and `bridge_mobile_location.zig` registers `CraftIOSLocationDelegate`,
1678 // and all of them are compiled into the same host test binary. Every one of
1679 // these looks its class up with `objc_getClass` first, so whichever
1680 // registered second would silently adopt the other's IMPs.
1681 try testing.expectEqualStrings("CraftIOSImagePickerDelegate", delegate_class_name);
1682 for ([_][]const u8{
1683 "CraftLocationDelegate",
1684 "CraftIOSLocationDelegate",
1685 "CraftScriptMessageHandler",
1686 }) |taken| {
1687 try testing.expect(!std.mem.eql(u8, delegate_class_name, taken));
1688 }
1689}
packages/zig/src/bridge_mobile_system.zigmodified+3-3
Changes to packages/zig/src/bridge_mobile_system.zig
@@ -355,7 +355,7 @@ fn failFromCallback(request_id: i64, action: []const u8, err: bridge_error.Bridg
355355/// The non-Darwin arm is not a stub that pretends to work — it returns
356356/// `error.UnsupportedPlatform`, which `ios_dispatch.asBridgeError` turns into
357357/// `PLATFORM_NOT_SUPPORTED` for the page.
358const uikit = if (is_darwin) struct {
358pub const uikit = if (is_darwin) struct {
359359 /// `[[UIApplication sharedApplication] openURL:options:completionHandler:]`
360360 ///
361361 /// The modern three-argument selector, never the deprecated one-argument
@@ -554,7 +554,7 @@ const uikit = if (is_darwin) struct {
554554 /// draws no arrow. It is emphatically not `UIPopoverArrowDirectionUnknown`,
555555 /// which is `NSUIntegerMax`: passing that would permit every direction and
556556 /// point an arrow at the middle of the screen.
557 fn anchorPopover(activity_vc: objc.id, presenter: objc.id) !void {
557 pub fn anchorPopover(activity_vc: objc.id, presenter: objc.id) !void {
558558 const sel_popover = objc.sel_registerName("popoverPresentationController") orelse
559559 return error.SelectorNotFound;
560560 const popover = objc.msgSendId(activity_vc, sel_popover);
@@ -669,7 +669,7 @@ const uikit = if (is_darwin) struct {
669669 /// presenting on a controller that is already presenting something logs
670670 /// "which is already presenting" and silently does nothing, so the
671671 /// completion handler never fires and the caller waits out its timeout.
672 fn topmostViewController() !objc.id {
672 pub fn topmostViewController() !objc.id {
673673 const webview = ios_dispatch.getWebView() orelse
674674 return bridge_error.BridgeError.WebViewHandleNotSet;
675675 if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet;
packages/zig/src/ios_async.zigmodified+66-5
Changes to packages/zig/src/ios_async.zig
@@ -62,6 +62,10 @@ const Slot = struct {
6262 /// A module-shaped reply, for completions this file cannot shape itself.
6363 /// Owned by the slot; freed on delivery. Null means use `granted`.
6464 json: ?[]u8 = null,
65 /// Which error to report when `failed`. Defaults to the generic one, so
66 /// `deliverError` keeps its meaning and only a caller that knows better
67 /// has to say so.
68 fail_code: bridge_error.BridgeError = bridge_error.BridgeError.NativeCallFailed,
6569 /// Set when the reply could not be produced at all. Distinct from a null
6670 /// `json`, which legitimately means "this was a BOOL completion" — without
6771 /// the flag, a failure falls through to the BOOL wording and resolves
@@ -230,6 +234,7 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
230234 var granted = false;
231235 var json: ?[]u8 = null;
232236 var failed = false;
237 var fail_code = bridge_error.BridgeError.NativeCallFailed;
233238 {
234239 slots_mutex.lock();
235240 defer slots_mutex.unlock();
@@ -241,8 +246,10 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
241246 granted = slot.granted;
242247 json = slot.json;
243248 failed = slot.failed;
249 fail_code = slot.fail_code;
244250 slot.json = null;
245251 slot.failed = false;
252 slot.fail_code = bridge_error.BridgeError.NativeCallFailed;
246253 slot.in_use = false;
247254 slot.generation +%= 1;
248255 }
@@ -256,11 +263,7 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
256263 defer request_context.pop();
257264
258265 if (failed) {
259 bridge_error.sendErrorToJS(
260 std.heap.c_allocator,
261 action,
262 bridge_error.BridgeError.NativeCallFailed,
263 );
266 bridge_error.sendErrorToJS(std.heap.c_allocator, action, fail_code);
264267 return;
265268 }
266269
@@ -305,6 +308,26 @@ pub fn deliverJson(ticket: Ticket, json: []const u8) void {
305308 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
306309}
307310
311/// Reply to a captured call with a *specific* error.
312///
313/// `deliverError` reports `NativeCallFailed`, which is right for "the answer
314/// could not be shaped" and wrong for everything else. A user tapping Cancel
315/// on a picker is not a native call failing — the call worked perfectly and
316/// the answer is "no". Telling the page otherwise sends anyone reading the
317/// error looking for a bug that is not there.
318pub fn deliverErrorCode(ticket: Ticket, err: bridge_error.BridgeError) void {
319 slots_mutex.lock();
320 const valid = slots[ticket.index].in_use and slots[ticket.index].generation == ticket.generation;
321 if (valid) {
322 slots[ticket.index].failed = true;
323 slots[ticket.index].fail_code = err;
324 }
325 slots_mutex.unlock();
326
327 if (!valid) return;
328 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain);
329}
330
308331/// Reply to a captured call with an error rather than a result.
309332///
310333/// A module's own completion can fail to shape its answer — a nil object where
@@ -425,3 +448,41 @@ test "a reply that could not be shaped is flagged, not silently downgraded" {
425448 slots[ticket.index].in_use = false;
426449 slots[ticket.index].generation +%= 1;
427450}
451
452test "a cancel reports being cancelled, not a native failure" {
453 // The distinction matters to whoever reads the error. A user tapping
454 // Cancel is not a native call failing — the call worked and the answer is
455 // "no" — and reporting NativeCallFailed sends a reader looking for a bug
456 // that is not there.
457 request_context.push(11);
458 const ticket = acquire("pickImage") orelse return error.PoolUnexpectedlyFull;
459 request_context.pop();
460
461 deliverErrorCode(ticket, bridge_error.BridgeError.Cancelled);
462
463 slots_mutex.lock();
464 defer slots_mutex.unlock();
465 try testing.expect(slots[ticket.index].failed);
466 try testing.expectEqual(bridge_error.BridgeError.Cancelled, slots[ticket.index].fail_code);
467
468 slots[ticket.index].failed = false;
469 slots[ticket.index].fail_code = bridge_error.BridgeError.NativeCallFailed;
470 slots[ticket.index].in_use = false;
471 slots[ticket.index].generation +%= 1;
472}
473
474test "the generic deliverError still means a native failure" {
475 // deliverErrorCode must not have changed what deliverError says: a
476 // shaping failure genuinely is a native call that did not produce an
477 // answer, and that is the default a slot resets to.
478 const ticket = acquire("x") orelse return error.PoolUnexpectedlyFull;
479 deliverError(ticket);
480
481 slots_mutex.lock();
482 defer slots_mutex.unlock();
483 try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, slots[ticket.index].fail_code);
484
485 slots[ticket.index].failed = false;
486 slots[ticket.index].in_use = false;
487 slots[ticket.index].generation +%= 1;
488}
packages/zig/src/ios_delegate.zigadded+193-0
Changes to packages/zig/src/ios_delegate.zig
@@ -0,0 +1,193 @@
1//! Building an Objective-C delegate class at runtime.
2//!
3//! Three files now need one — the script message handler in `ios.zig`, the
4//! `CLLocationManagerDelegate` in `bridge_mobile_location.zig`, and every
5//! presented picker (image, document, contact), each of which answers through
6//! a did-finish/did-cancel pair. The registration dance is the same every
7//! time: `objc_getClass` first so a second call is a no-op, then
8//! `objc_allocateClassPair`, then a `class_addMethod` per selector with its
9//! type encoding, then `objc_registerClassPair`.
10//!
11//! ## What this deliberately does not own
12//!
13//! It does not route callbacks to pending requests. That was tempting and
14//! would have been wrong: a location delegate serves a one-shot *and* a
15//! running stream simultaneously, a picker is modal and serves exactly one
16//! call, and a message handler serves none — it dispatches. Three different
17//! lifetimes, and a factory that guessed at one would push the other two into
18//! working around it. Each module keeps its own state and its own `ios_async`
19//! ticket; this only makes the class exist.
20//!
21//! It also does not retain the delegate *instance*. `instantiate` returns a
22//! +1 object and says so; the caller stores it in a module-level var, because
23//! only the caller knows whether the framework holds the delegate weakly
24//! (`CLLocationManager` and `UIImagePickerController` both do — a released
25//! instance is a crash, not a leak).
26//!
27//! ## Type encodings, which are the part that fails silently
28//!
29//! `class_addMethod`'s encoding string is not checked against the IMP. Get it
30//! wrong and the method still registers, still gets called, and reads its
31//! arguments from the wrong registers. There is no compile error and often no
32//! crash — just wrong values. The `enc` helpers below name the shapes these
33//! delegates actually use so a caller picks one rather than spelling it.
34
35const std = ;
36const builtin = ;
37const objc_runtime = ;
38
39const objc = objc_runtime.objc;
40
41/// Type encodings for the delegate signatures in use.
42///
43/// Every Objective-C method takes `self` (`@`) and `_cmd` (`:`) first; the
44/// leading character is the return type. Named rather than spelled at call
45/// sites because a typo here is invisible until an argument reads as garbage.
46pub const enc = struct {
47 /// `- (void)m:(id)a` — one object argument.
48 pub const void_one_object: [:0]const u8 = "v@:@";
49 /// `- (void)m:(id)a :(id)b` — two object arguments. The
50 /// did-finish-picking pair and `userContentController:didReceiveScriptMessage:`
51 /// are both this shape.
52 pub const void_two_objects: [:0]const u8 = "v@:@@";
53 /// `- (BOOL)m:(id)a :(id)b` — `application:didFinishLaunchingWithOptions:`.
54 ///
55 /// `B` is C99 `_Bool`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is
56 /// defined, so `BOOL` is `bool` rather than `signed char`.
57 pub const bool_two_objects: [:0]const u8 = "B@:@@";
58};
59
60/// One method to add to a class.
61pub const Method = struct {
62 /// The selector, e.g. `"imagePickerControllerDidCancel:"`.
63 selector: [:0]const u8,
64 /// The implementation. Must be an `export fn ... callconv(.c)` whose first
65 /// two parameters are `objc.id` (self) and `objc.SEL` (_cmd).
66 imp: *const anyopaque,
67 /// The type encoding. Use one of `enc`.
68 types: [:0]const u8,
69};
70
71pub const Error = error{
72 UnsupportedPlatform,
73 ClassNotFound,
74 ClassAllocationFailed,
75 MethodNotAdded,
76 SelectorNotFound,
77 InstantiationFailed,
78};
79
80/// Register `name` as a subclass of `superclass_name` carrying `methods`.
81///
82/// Idempotent: a second call with the same name returns the existing class
83/// without touching it. That matters because these registrations happen lazily
84/// on first use, and "first use" can be reached from more than one action.
85///
86/// A partially-built class is not registered. If any `class_addMethod` fails
87/// the pair is abandoned rather than registered with a missing method — a
88/// class whose delegate method is absent silently does nothing when the
89/// framework calls it, which is the failure this whole migration keeps
90/// finding.
91pub fn defineClass(
92 name: [:0]const u8,
93 superclass_name: [:0]const u8,
94 methods: []const Method,
95) Error!objc.Class {
96 if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform;
97
98 if (objc.objc_getClass(name.ptr)) |existing| return existing;
99
100 const superclass = objc.objc_getClass(superclass_name.ptr) orelse return Error.ClassNotFound;
101 const cls = objc.objc_allocateClassPair(superclass, name.ptr, 0) orelse
102 return Error.ClassAllocationFailed;
103
104 for (methods) |method| {
105 const sel = objc.sel_registerName(method.selector.ptr) orelse return Error.SelectorNotFound;
106 const imp: objc.IMP = @ptrCast(@constCast(method.imp));
107 if (!objc.class_addMethod(cls, sel, imp, method.types.ptr)) {
108 // Deliberately not registered. An unregistered class pair is
109 // leaked, which is one allocation for the life of the process and
110 // strictly better than a registered class that answers a delegate
111 // callback by doing nothing.
112 return Error.MethodNotAdded;
113 }
114 }
115
116 objc.objc_registerClassPair(cls);
117 return cls;
118}
119
120/// `[[cls alloc] init]`, +1.
121///
122/// The caller owns the reference and must keep it alive for as long as the
123/// framework might call back. Every delegate protocol here is held weakly by
124/// its owner, so storing this in a module-level var is not an optimisation —
125/// dropping it is a use-after-free the first time the user taps Cancel.
126pub fn instantiate(cls: objc.Class) Error!objc.id {
127 if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform;
128 return objc.allocInit(cls) catch Error.InstantiationFailed;
129}
130
131const testing = std.testing;
132
133test "a class is registered once and re-registration returns the same class" {
134 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
135
136 const first = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{});
137 const second = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{});
138
139 // Same pointer, not merely both non-null: a second allocateClassPair for a
140 // taken name returns null, so a factory that did not check first would
141 // fail on the second call rather than returning what exists.
142 try testing.expectEqual(first, second);
143 try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe") != null);
144}
145
146test "a method added by the factory is actually reachable by selector" {
147 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
148
149 const S = struct {
150 var called: bool = false;
151 export fn craftDelegateFactoryProbeMethod(_: objc.id, _: objc.SEL, _: objc.id) void {
152 called = true;
153 }
154 };
155
156 const cls = try defineClass("CraftDelegateFactoryProbe2", "NSObject", &.{
157 .{
158 .selector = "craftProbe:",
159 .imp = @ptrCast(&S.craftDelegateFactoryProbeMethod),
160 .types = enc.void_one_object,
161 },
162 });
163
164 const instance = try instantiate(cls);
165 const sel = objc.sel_registerName("craftProbe:") orelse return error.SelectorNotFound;
166
167 // The point of the test: registration is not enough, the runtime has to
168 // dispatch to it. A wrong encoding still registers and still dispatches,
169 // so this proves reachability rather than correctness of the encoding —
170 // which nothing can check but a real call with real arguments.
171 objc.msgSendVoid1(instance, sel, @as(objc.id, null));
172 try testing.expect(S.called);
173}
174
175test "an unknown superclass is refused rather than silently subclassing NSObject" {
176 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
177
178 try testing.expectError(
179 Error.ClassNotFound,
180 defineClass("CraftDelegateFactoryProbe3", "NoSuchSuperclassExists", &.{}),
181 );
182 // And nothing was left half-built under that name.
183 try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe3") == null);
184}
185
186test "the encodings name the shapes these delegates actually use" {
187 // A typo in an encoding registers fine and reads arguments from the wrong
188 // registers — no compile error, often no crash, just wrong values. Naming
189 // them is the only defence available, so the names are pinned.
190 try testing.expectEqualStrings("v@:@", enc.void_one_object);
191 try testing.expectEqualStrings("v@:@@", enc.void_two_objects);
192 try testing.expectEqualStrings("B@:@@", enc.bool_two_objects);
193}
packages/zig/src/ios_dispatch.zigmodified+8-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -5,6 +5,7 @@ const request_context = ;
55const bridge_error = ;
66pub const ios_async = ;
77pub const ios_events = ;
8pub const ios_delegate = ;
89const bridge_mobile = ;
910const bridge_mobile_clipboard = ;
1011const bridge_mobile_haptics = ;
@@ -25,6 +26,9 @@ const bridge_mobile_watch = ;
2526const bridge_mobile_location = ;
2627const bridge_mobile_locrecording = ;
2728const bridge_mobile_motion = ;
29const bridge_mobile_imagepicker = ;
30const bridge_mobile_filepicker = ;
31const bridge_mobile_contactpicker = ;
2832
2933const objc = objc_runtime.objc;
3034
@@ -232,6 +236,9 @@ const mobile_bridges = .{
232236 bridge_mobile_location.LocationBridge,
233237 bridge_mobile_locrecording.LocationRecordingBridge,
234238 bridge_mobile_motion.MotionBridge,
239 bridge_mobile_imagepicker.ImagePickerBridge,
240 bridge_mobile_filepicker.FilePickerBridge,
241 bridge_mobile_contactpicker.ContactPickerBridge,
235242};
236243
237244/// Narrow an arbitrary handler error to one the page's error codes can express.
@@ -388,6 +395,7 @@ fn buildShimError(
388395test {
389396 _ = ios_async;
390397 _ = ios_events;
398 _ = ios_delegate;
391399}
392400
393401const testing = std.testing;
packages/zig/test/ios_conformance_test.zigmodified+7-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -44,6 +44,9 @@ const zig_sources = [_][]const u8{
4444 @embedFile("src/bridge_mobile_location.zig"),
4545 @embedFile("src/bridge_mobile_locrecording.zig"),
4646 @embedFile("src/bridge_mobile_motion.zig"),
47 @embedFile("src/bridge_mobile_imagepicker.zig"),
48 @embedFile("src/bridge_mobile_filepicker.zig"),
49 @embedFile("src/bridge_mobile_contactpicker.zig"),
4750};
4851
4952/// The action list lives in the `switch action` block, and nowhere else.
@@ -81,7 +84,10 @@ const dispatch_end = "func webView(";
8184/// would make Zig dispatch and refuse an action the shim serves correctly.
8285/// 58 after the event channel landed and unblocked the location tier: the
8386/// first actions that push a stream to the page rather than answering a call.
84const max_not_yet_migrated: usize = 58;
87/// 53 after the presented pickers — image, document and contact — landed on
88/// the delegate factory, the last of the three mechanisms this migration
89/// needed. Everything remaining is a repetition of a proven pattern.
90const max_not_yet_migrated: usize = 53;
8591
8692fn dispatcherRegion() []const u8 {
8793 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";