ReviewOS

also looking at this

craft-native/craft

iOS Phase 9: calendar and contacts — ratchet 53 → 48

#91
Merged glennmichael123 wants to merge feat/ios-phase9-data into main
6 files +5708 -1
packages/zig/src/bridge_mobile_contacts.zigadded+2479-0
Changes to packages/zig/src/bridge_mobile_contacts.zig
@@ -0,0 +1,2479 @@
1//! `getContacts` and `addContact` — the address book itself, read and written
2//! through `CNContactStore`.
3//!
4//! The Swift original is `CraftApp.swift:705-713` (dispatch), `3224-3258`
5//! (`getContacts`), `3260-3287` (`addContact`), `1763-1780` (the injected JS
6//! that decides the wire payload) and `402-403`/`449-451` (the one store
7//! instance). Every claim below is measured against those lines.
8//!
9//! This is a sibling of `bridge_mobile_contactpicker.zig` and not a variation
10//! on it: the picker runs out of process, needs no authorization and answers a
11//! *selection*; these two ask TCC for access to the whole book and then read or
12//! write it. The reply shapes differ too — see "Two writers" below.
13//!
14//! ## The payloads
15//!
16//! **`getContacts` sends nothing.** `craft.contacts.getAll()` posts
17//! `{action:'getContacts', callbackId: id}` and Swift's case reads nothing off
18//! `body`, so `d` arrives as `{}`. There is no limit, no offset and no paging
19//! anywhere in the spec. The handler therefore takes no `data` parameter at
20//! all — a test pins that signature — because a body this action never reads
21//! must not be able to fail the call: rejecting a malformed `d` here would fail
22//! a call the shim answers.
23//!
24//! **`addContact` sends one field, `contact`, and it is a nested object.**
25//! `craft.contacts.add(contact)` posts `{action:'addContact', contact: contact,
26//! callbackId: id}`, so the stringified `d` has a root key `contact` whose
27//! value is an object. Swift reads exactly four keys out of it, each with
28//! `as? String`:
29//!
30//! `givenName`, `familyName` -> set directly on a `CNMutableContact`;
31//! `phone` -> one `CNLabeledValue(label: CNLabelPhoneNumberMain,
32//! value: CNPhoneNumber(stringValue:))`;
33//! `email` -> one `CNLabeledValue(label: CNLabelHome, value: email as NSString)`.
34//!
35//! `as? String` is ported exactly, and it is not a coercion: a non-string value
36//! (`{"phone": 5551234}`, a bool, a null, an object) makes the `if let` fail and
37//! leaves the property *unset*. It is not an error, and it is emphatically not
38//! "stringify it anyway". `contactFieldFrom` below is that rule, and the tests
39//! pin it.
40//!
41//! **`displayName` on the way in is dropped, deliberately.**
42//! `craft.d.ts:1166-1172` declares `NewContact.displayName?` and
43//! `test-bridges.html:816` sends it, but `CraftApp.swift:3267-3275` never reads
44//! it and `CNMutableContact` has no settable `displayName` — it is derived from
45//! the name components. Dropping it is spec parity, not a Zig regression, and
46//! saying so here is the point: silently omitting it would read as an oversight
47//! the next time somebody diffs the two.
48//!
49//! ## The replies, which are two different shapes
50//!
51//! `resolveCallback` serialises with `.fragmentsAllowed`, so bare fragments are
52//! real answers rather than a mistake.
53//!
54//! - **`getContacts` resolves a JSON array** of six-key objects:
55//! `{"id","givenName","familyName","displayName","phoneNumbers",
56//! "emailAddresses"}`, where `phoneNumbers` and `emailAddresses` are **flat
57//! arrays of strings** (`phone.value.stringValue`, `email.value as String`).
58//! An empty address book is `[]`, and `[]` **resolves** — a fresh Simulator
59//! answers exactly that, `test-bridges.html:804` prints `Found 0 contacts`,
60//! and reporting it as a failure would be a lie about a call that worked.
61//! - **`addContact` resolves a bare JSON string**: Swift passes
62//! `contact.identifier`, a `String`, through `.fragmentsAllowed`, which
63//! serialises to `"ABCD-1234-..."` *including the quotes*. The page does
64//! `'Contact created with ID: ' + id`. So the reply bytes are a quoted,
65//! escaped string literal — the same shape `ios_async` already sends for
66//! `"granted"`.
67//!
68//! Swift builds `[String: Any]`, whose key order is a `Dictionary`'s and
69//! therefore arbitrary; one order is fixed here so the bytes are testable, the
70//! same call `bridge_mobile_contactpicker.appendContact` makes.
71//!
72//! **Neither action emits anything.** There is no `sendToWeb` on any contacts
73//! path in `CraftApp.swift` and no contacts listener in `test-bridges.html`.
74//! `ios_events.Event` has no contacts member and must not grow one for this:
75//! an event here would be a message the shim never sends.
76//!
77//! ## Two writers, on purpose
78//!
79//! `bridge_mobile_contactpicker.zig` also serialises `CNContact`s, and its
80//! `formatContact` port emits `phoneNumbers: [{label, number}]` /
81//! `emailAddresses: [{label, address}]`. This file emits flat `string[]`. Both
82//! are correct: they are two Swift functions with two shapes
83//! (`CraftApp.swift:3244-3251` here, `5106-5136` there), and
84//! `craft.d.ts:1157-1164` — which declares `string[]` — agrees with *this* one
85//! and is wrong for the picker. Unifying them would break one of the two pages
86//! that consume them, so the two writers stay, and each names its Swift source.
87//!
88//! The reading layer is a different story and worth stating plainly: `Sels`,
89//! `readString`, `infoPlistValue` and `requireContactsConfigured` here overlap
90//! the picker's file-private versions. Sharing them is a one-line-per-decl
91//! `pub` change in that file plus an import, and it was left undone only
92//! because this round was scoped to one new file. Two things would still not
93//! have been shared: the picker's `readLabeledValues` computes a localized
94//! label via `+[CNLabeledValue localizedStringForLabel:]` that `getContacts`
95//! throws away — once per value across an entire address book — and the
96//! picker's `Sels.resolve` hard-fails when `CNContactFormatter` is absent, a
97//! class neither of these actions uses. So the honest note is "duplicated, ~70
98//! lines, remedy known", not "could not be shared".
99//!
100//! ## The Info.plist gate is a real precondition here
101//!
102//! `packages/ios/src/index.ts:189` writes `NSContactsUsageDescription` if and
103//! only if `config.enableContacts`, which is what makes the key readable as
104//! evidence of the flag Swift guards both cases with. But unlike the picker —
105//! which qualifies its own gate as *only* evidence — this is also the
106//! framework's own precondition: `requestAccessForEntityType:` in a process
107//! without that key does not return an error, TCC **kills the process**. From
108//! Zig that is an uncatchable SIGABRT, so the key is checked first on both
109//! actions, before `CNContactStore` is touched.
110//!
111//! `CNContactStore` itself is guarded separately and named in the log, because
112//! `packages/ios/fixtures/zig-slice` links neither Contacts nor ContactsUI and
113//! the fix for that is a link line rather than anything in this file.
114//!
115//! ## What diverges from the shim, stated rather than smoothed over
116//!
117//! **A denial's code and message change.** Swift rejects with
118//! `code: "CRAFT_ERROR"` and `error?.localizedDescription ?? "Permission
119//! denied"` (3226) / `"Permission denied"` (3263). Here a denial is
120//! `ios_async.deliverErrorCode(ticket, PermissionDenied)`, which the page sees
121//! as `code:"PERMISSION_DENIED"`, `message:"Permission denied"`. The code is
122//! *more* accurate — a user declining a prompt is not a native call failing —
123//! and a page branching on `err.code` still sees a change.
124//! `test-bridges.html:806/822` prints `e.message`.
125//!
126//! **`localizedDescription` cannot be carried.** `ios_async` transports a
127//! `BridgeError` enum and nothing else, so an `NSError`'s domain, code and
128//! description are logged where they happen and never reach the page. Inventing
129//! a second reply channel to carry them would reach around the generation guard
130//! that stops a stale completion answering somebody else's call.
131//!
132//! **A missing or non-object `contact` rejects where Swift hangs.**
133//! `case "addContact"` has no `else` (709-713), and the promise it leaves
134//! unanswered is hand-built (1772-1779) rather than made by `_createCallback`,
135//! so it carries none of the 30-second timeout at 1196-1209: the page waits
136//! forever. Here it is `MISSING_DATA` (absent) or `INVALID_PARAMETER` (present
137//! but not an object). Everybody settles, which is strictly better, and it is a
138//! difference a page can observe. The same is true of `enableContacts: false`,
139//! which Swift answers with silence on both actions and this file answers with
140//! `PERMISSION_DENIED`.
141//!
142//! **An embedded NUL is refused rather than truncated.** On the way *in*,
143//! `objc.createNSString` goes through `stringWithUTF8String:`, which stops at
144//! the first NUL — so a `givenName` containing U+0000 would be silently stored
145//! as its prefix. That is a wrong value written to the user's address book, so
146//! it is rejected as `INVALID_PARAMETER` instead; Swift would have stored the
147//! whole string. On the way *out*, `readString`'s length check fails the whole
148//! `getContacts` for one such contact, which is the picker's pre-existing
149//! divergence inherited unchanged rather than a new one.
150//!
151//! **An unfetched key still crashes.** Touching a property outside
152//! `keysToFetch` raises `CNContactPropertyNotFetchedException`, an uncatchable
153//! SIGABRT from Zig. Swift cannot catch `NSException` either, so this is parity
154//! — and there is deliberately no comment below claiming a guard, because there
155//! is none. The five keys are fetched precisely because of it.
156//!
157//! **iOS 18 limited access is reported as what it returned.**
158//! `requestAccessForEntityType:` can answer `granted = YES` with only a
159//! user-chosen subset visible; enumeration then walks that subset. The array is
160//! what was actually returned, and nothing here annotates it with a
161//! completeness claim the framework did not make.
162//!
163//! ## Concurrency
164//!
165//! Unlike the picker, which owns one on-screen resource and refuses a second
166//! call, nothing here is exclusive: two `getContacts` in flight are two reads.
167//! So the pending state is one entry per `ios_async` slot — the
168//! `bridge_mobile_notifications.zig` pattern — and each slot is leased
169//! exclusively by its ticket, which is what makes a plain write to
170//! `pending_calls[ticket.index]` safe. The mutex is still held on every access,
171//! because "the completion should be on the main thread" is not a guard and
172//! `requestAccessForEntityType:` promises no queue at all.
173//!
174//! `enumerateContactsWithFetchRequest:error:usingBlock:` is *synchronous* — its
175//! block runs on the calling thread and the call returns when the walk ends —
176//! so the enumeration state is a pointer to a stack frame that provably
177//! outlives every fire, published under its own mutex for the duration of the
178//! call and cleared immediately after.
179//!
180//! No reply is ever sent from a completion directly. `ios_async` is the only
181//! channel, and its main-queue hop is not merely thread safety: it restores the
182//! `request_context` captured at dispatch, so the reply names the call that is
183//! waiting instead of falling back to action-name matching.
184//!
185//! ## A loose end that belongs to the page, not to this file
186//!
187//! `test-bridges.html:803/813` calls `window.craft.getContacts()` and
188//! `window.craft.addContact({...})`, but the injected JS defines only
189//! `window.craft.contacts.getAll()` and `.add(contact)`. As written the test
190//! page throws `TypeError` into its own `catch` and never reaches the bridge,
191//! so a green run of it proves nothing about either action. The wire contract
192//! above comes from the injected JS (the only authority for what is posted) and
193//! from Swift's resolve calls; `craft.d.ts:258/265`, `packages/ios/README.md`
194//! and `examples/basic-app.ts` agree on the *shapes* even where they use the
195//! flat names.
196
197const std = ;
198const builtin = ;
199const capabilities = ;
200const bridge_error = ;
201const objc_runtime = ;
202const ios_async = ;
203const compat_mutex = ;
204
205const objc = objc_runtime.objc;
206const is_darwin = builtin.target.os.tag.isDarwin();
207
208/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
209/// picker/location/notifications precedent: `objc_runtime.objc` is an empty
210/// struct off Darwin and a `callconv(.c)` function *signature* is analysed even
211/// where a comptime platform guard prunes the body. A single optional pointer,
212/// never `?objc.id`, which would be a double optional and illegal in
213/// `callconv(.c)`.
214const Id = ?*anyopaque;
215
216/// The action names, spelled exactly as the Swift `case` labels spell them.
217/// `test/ios_conformance_test.zig` matches this block against those labels in
218/// both directions and fails the build if two modules declare one name.
219pub const A = struct {
220 pub const get_contacts = "getContacts";
221 pub const add_contact = "addContact";
222};
223
224/// `.result` for both: every Swift path out of either action terminates in
225/// exactly one `resolveCallback` or one `rejectCallback`. `.none` would claim
226/// nothing answers, and on these hand-built promises — no `_createCallback`, no
227/// `setTimeout` — that is a page parked for its lifetime rather than 30
228/// seconds.
229///
230/// `.live`, not `.unavailable`: in an app built with `enableContacts` both work
231/// end to end, on the Simulator included. Every refusal below is a specific
232/// condition (not configured, Contacts unlinked, access declined, the async
233/// pool full), never the normal answer.
234pub const capability_actions = [_]capabilities.ActionDecl{
235 .{ .name = A.get_contacts, .reply = .result },
236 .{ .name = A.add_contact, .reply = .result },
237};
238
239/// Which handler an action selects, split out from `handleMessage` so the
240/// table-versus-dispatch agreement is assertable on a host without touching
241/// Contacts.framework.
242const Route = enum { get_contacts, add_contact };
243
244fn routeFor(action: []const u8) ?Route {
245 if (std.mem.eql(u8, action, A.get_contacts)) return .get_contacts;
246 if (std.mem.eql(u8, action, A.add_contact)) return .add_contact;
247 return null;
248}
249
250pub const ContactsBridge = struct {
251 allocator: std.mem.Allocator,
252
253 const Self = @This();
254
255 pub fn init(allocator: std.mem.Allocator) Self {
256 return .{ .allocator = allocator };
257 }
258
259 pub fn deinit(_: *Self) void {}
260
261 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
262 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
263 // Exhaustive, so a `Route` without a handler is a compile error.
264 return switch (route) {
265 .get_contacts => self.getContacts(),
266 .add_contact => self.addContact(data),
267 };
268 }
269
270 /// Ask for access, then enumerate the whole book.
271 ///
272 /// No `data` parameter, and that is the contract rather than an omission:
273 /// Swift reads nothing off the body, so there is no field to drop and no
274 /// malformed payload that may fail this call.
275 ///
276 /// Ordering is load-bearing. Every fallible step — the Info.plist gate, the
277 /// store, every selector the completion and the enumeration will need, the
278 /// five key constants, the fetch request — runs *before*
279 /// `ios_async.acquire`, because after the lease a failure can only be
280 /// logged. No path leaves after the lease without reaching the framework
281 /// call — `publishPendingCall` and the `msgSend` are both infallible — and
282 /// a future edit that adds one must `ios_async.abandon` on its way out or
283 /// the slot narrows the pool for the life of the process.
284 fn getContacts(_: *Self) !void {
285 if (!is_darwin) return error.UnsupportedPlatform;
286
287 try requireContactsConfigured(A.get_contacts);
288
289 const store = try contactStore();
290 const sels = try ReadSels.resolve();
291 // +1. Released by the completion, on every path including denial.
292 const request = try fetchRequest();
293 errdefer objc.release(request);
294
295 const ticket = ios_async.acquire(A.get_contacts) orelse return poolFull(A.get_contacts);
296
297 // Published *before* the framework call, never after: a completion that
298 // fired first would otherwise find no ticket to answer with.
299 publishPendingCall(.{
300 .ticket = ticket,
301 .store = store.object,
302 .work = .{ .get_contacts = .{ .request = request, .sels = sels } },
303 });
304
305 requestAccess(store, ticket);
306 }
307
308 /// Build the contact synchronously, then ask for access and save it.
309 ///
310 /// The `CNMutableContact` and its `CNSaveRequest` are assembled here rather
311 /// than in the completion for the reason above: building them needs no
312 /// authorization, and doing it at dispatch keeps every fallible
313 /// Objective-C step ahead of the lease — while also avoiding a copy of four
314 /// payload strings into slot storage that outlives this frame. Both objects
315 /// are +1 and both are released by the completion, denial included.
316 fn addContact(self: *Self, data: []const u8) !void {
317 var fields = try parseNewContact(self.allocator, data);
318 defer fields.deinit(self.allocator);
319
320 if (!is_darwin) return error.UnsupportedPlatform;
321
322 try requireContactsConfigured(A.add_contact);
323
324 const store = try contactStore();
325 const sels = try SaveSels.resolve();
326 const build = try BuildSels.resolve();
327
328 const contact = try buildContact(self.allocator, fields, build);
329 errdefer objc.release(contact);
330
331 const save = try buildSaveRequest(contact, build);
332 errdefer objc.release(save);
333
334 const ticket = ios_async.acquire(A.add_contact) orelse return poolFull(A.add_contact);
335
336 publishPendingCall(.{
337 .ticket = ticket,
338 .store = store.object,
339 .work = .{ .add_contact = .{ .contact = contact, .save = save, .sels = sels } },
340 });
341
342 requestAccess(store, ticket);
343 }
344};
345
346/// The answer for a full block pool, copied from `bridge_mobile_location` and
347/// the picker: `BridgeError` has no "Busy", `INVALID_PARAMETER` is the
348/// migration notes' designated stand-in, and the point is that the caller gets
349/// an explicit rejection instead of a promise that never settles.
350fn poolFull(action: []const u8) bridge_error.BridgeError {
351 std.log.warn(
352 "{s} refused: all {d} async slots are in flight",
353 .{ action, ios_async.max_in_flight },
354 );
355 return bridge_error.BridgeError.InvalidParameter;
356}
357
358// =============================================================================
359// Payload parsing. Pure — no Objective-C — so the exact `as? String` rule Swift
360// applies is pinned by host tests on every platform.
361// =============================================================================
362
363/// The four fields Swift reads out of `contact`, each present only if it
364/// arrived as a JSON string. `null` means "Swift's `if let` would not have
365/// fired", which leaves the property unset — not empty, unset.
366const NewContact = struct {
367 given_name: ?[]const u8 = null,
368 family_name: ?[]const u8 = null,
369 phone: ?[]const u8 = null,
370 email: ?[]const u8 = null,
371
372 /// The strings are copies: the parsed JSON tree is freed before the
373 /// `NSString`s are built from them.
374 fn deinit(self: *NewContact, allocator: std.mem.Allocator) void {
375 if (self.given_name) |s| allocator.free(s);
376 if (self.family_name) |s| allocator.free(s);
377 if (self.phone) |s| allocator.free(s);
378 if (self.email) |s| allocator.free(s);
379 self.* = .{};
380 }
381};
382
383/// `body["contact"] as? [String: Any]`, plus the rejection Swift does not have.
384///
385/// A malformed `d`, a non-object `d`, an absent `contact` and a non-object
386/// `contact` are four different facts and are reported as three different
387/// errors. Swift collapses all of them into "answer nothing", on a promise with
388/// no timeout — see the module comment.
389fn parseNewContact(allocator: std.mem.Allocator, data: []const u8) !NewContact {
390 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
391 return bridge_error.BridgeError.InvalidJSON;
392 defer parsed.deinit();
393
394 const root = switch (parsed.value) {
395 .object => |obj| obj,
396 else => return bridge_error.BridgeError.InvalidJSON,
397 };
398
399 const contact_value = root.get("contact") orelse {
400 std.log.warn(
401 "addContact refused: the message carries no `contact` object " ++
402 "(Swift answers such a call with nothing at all)",
403 .{},
404 );
405 return bridge_error.BridgeError.MissingData;
406 };
407
408 const contact = switch (contact_value) {
409 .object => |obj| obj,
410 else => {
411 std.log.warn(
412 "addContact refused: `contact` is not an object, so there is nothing to save",
413 .{},
414 );
415 return bridge_error.BridgeError.InvalidParameter;
416 },
417 };
418
419 var out = NewContact{};
420 errdefer out.deinit(allocator);
421
422 out.given_name = try contactFieldFrom(allocator, contact, "givenName");
423 out.family_name = try contactFieldFrom(allocator, contact, "familyName");
424 out.phone = try contactFieldFrom(allocator, contact, "phone");
425 out.email = try contactFieldFrom(allocator, contact, "email");
426
427 return out;
428}
429
430/// One `data["key"] as? String`, as a total function.
431///
432/// Absent is null. Present-but-not-a-string is *also* null, because that is
433/// what `as? String` yields and what Swift's `if let` then skips: erroring on
434/// `{"phone": 5551234}` would refuse a call the shim accepts, and stringifying
435/// it would write a value the page never sent.
436///
437/// The one refusal is an embedded NUL, which `stringWithUTF8String:` would
438/// truncate silently — a wrong value in the user's address book. See the module
439/// comment.
440fn contactFieldFrom(
441 allocator: std.mem.Allocator,
442 contact: std.json.ObjectMap,
443 comptime key: []const u8,
444) !?[]const u8 {
445 const value = contact.get(key) orelse return null;
446
447 const text = switch (value) {
448 .string => |s| s,
449 // A number (including `number_string`, which is still a number), a
450 // bool, a null, an array, an object. `as? String` is nil for every one.
451 else => {
452 std.log.info(
453 "addContact: `" ++ key ++ "` is not a string, so it is left unset — " ++
454 "the same thing Swift's `as? String` does with it",
455 .{},
456 );
457 return null;
458 },
459 };
460
461 if (std.mem.indexOfScalar(u8, text, 0) != null) {
462 std.log.warn(
463 "addContact refused: `" ++ key ++ "` contains an embedded NUL, which " ++
464 "stringWithUTF8String: would silently truncate",
465 .{},
466 );
467 return bridge_error.BridgeError.InvalidParameter;
468 }
469
470 return try allocator.dupe(u8, text);
471}
472
473// =============================================================================
474// Reply shaping. Pure — no Objective-C — so the exact bytes the page receives
475// are pinned by host tests.
476// =============================================================================
477
478/// One contact as `CraftApp.swift:3244-3251` assembles it.
479///
480/// `phones` and `emails` are flat strings, not `{label, ...}` pairs: this is
481/// the `getContacts` shape, and the picker's is the other one. See "Two
482/// writers" in the module comment before making these agree.
483///
484/// The strings borrow whatever buffer they were read from — on the native path
485/// an `NSString`'s internal UTF-8 buffer, valid for the enclosing autorelease
486/// pool — and every one of them is copied into the JSON before that pool ends.
487const ContactRow = struct {
488 id: []const u8,
489 given_name: []const u8,
490 family_name: []const u8,
491 display_name: []const u8,
492 phones: []const []const u8,
493 emails: []const []const u8,
494};
495
496fn appendJsonString(
497 allocator: std.mem.Allocator,
498 out: *std.ArrayListUnmanaged(u8),
499 s: []const u8,
500) !void {
501 try out.append(allocator, '"');
502 // Names, numbers and addresses are user data and will contain `"` and `\`
503 // in the wild. The reply is replayed into the source `evaluateJavaScript:`
504 // parses, so an unescaped quote is a syntax error in the page rather than a
505 // wrong field.
506 try bridge_error.appendJsonEscaped(allocator, out, s);
507 try out.append(allocator, '"');
508}
509
510fn appendStringArray(
511 allocator: std.mem.Allocator,
512 out: *std.ArrayListUnmanaged(u8),
513 items: []const []const u8,
514) !void {
515 try out.append(allocator, '[');
516 for (items, 0..) |item, i| {
517 if (i != 0) try out.append(allocator, ',');
518 try appendJsonString(allocator, out, item);
519 }
520 try out.append(allocator, ']');
521}
522
523/// One contact as a JSON object, in a fixed key order.
524///
525/// Swift's `[String: Any]` has no order at all; fixing one here is what makes
526/// the bytes testable. Both array keys are always written, so a page reading
527/// `contact.phoneNumbers.length` never meets `undefined`.
528fn appendContactRow(
529 allocator: std.mem.Allocator,
530 out: *std.ArrayListUnmanaged(u8),
531 row: ContactRow,
532) !void {
533 try out.appendSlice(allocator, "{\"id\":");
534 try appendJsonString(allocator, out, row.id);
535 try out.appendSlice(allocator, ",\"givenName\":");
536 try appendJsonString(allocator, out, row.given_name);
537 try out.appendSlice(allocator, ",\"familyName\":");
538 try appendJsonString(allocator, out, row.family_name);
539 try out.appendSlice(allocator, ",\"displayName\":");
540 try appendJsonString(allocator, out, row.display_name);
541 try out.appendSlice(allocator, ",\"phoneNumbers\":");
542 try appendStringArray(allocator, out, row.phones);
543 try out.appendSlice(allocator, ",\"emailAddresses\":");
544 try appendStringArray(allocator, out, row.emails);
545 try out.append(allocator, '}');
546}
547
548/// The `getContacts` reply, built one contact at a time.
549///
550/// Incremental because the source is an enumeration callback rather than a
551/// list: `enumerateContactsWithFetchRequest:` hands over one `CNContact` at a
552/// time and there is no count to size anything from. The alternative — collect
553/// every contact into a slice of borrowed slices first — would keep an entire
554/// address book's worth of `NSString` buffers alive on the promise that the
555/// autorelease pool outlives the walk, which is exactly the kind of claim this
556/// file should not be making.
557///
558/// The bracket and comma bookkeeping lives here rather than at the call site so
559/// the native path and the tests build the array through the same code.
560const RowWriter = struct {
561 out: std.ArrayListUnmanaged(u8) = .empty,
562 rows: usize = 0,
563
564 fn begin(self: *RowWriter, allocator: std.mem.Allocator) !void {
565 try self.out.append(allocator, '[');
566 }
567
568 fn append(self: *RowWriter, allocator: std.mem.Allocator, row: ContactRow) !void {
569 if (self.rows != 0) try self.out.append(allocator, ',');
570 try appendContactRow(allocator, &self.out, row);
571 self.rows += 1;
572 }
573
574 /// `[]` for an empty address book, which **resolves**. See the module
575 /// comment: a fresh Simulator answers exactly that and it is a correct
576 /// answer, not a failure.
577 fn finish(self: *RowWriter, allocator: std.mem.Allocator) ![]u8 {
578 try self.out.append(allocator, ']');
579 return self.out.toOwnedSlice(allocator);
580 }
581
582 fn deinit(self: *RowWriter, allocator: std.mem.Allocator) void {
583 self.out.deinit(allocator);
584 }
585};
586
587/// The `addContact` reply: a bare, quoted, escaped JSON string.
588///
589/// Not an object, and not the identifier raw. Swift resolves the `String`
590/// itself under `.fragmentsAllowed`, so `{"id": ...}` here would make the
591/// page's `'ID: ' + id` print `[object Object]`.
592fn shapeIdentifier(allocator: std.mem.Allocator, identifier: []const u8) ![]u8 {
593 var out: std.ArrayListUnmanaged(u8) = .empty;
594 errdefer out.deinit(allocator);
595 try appendJsonString(allocator, &out, identifier);
596 return out.toOwnedSlice(allocator);
597}
598
599/// `"\(givenName) \(familyName)"` — always exactly one space, before trimming.
600///
601/// The trim itself is deliberately *not* done here. Swift's
602/// `trimmingCharacters(in: .whitespaces)` is Unicode general category Zs plus
603/// U+0009, both ends only, interior runs preserved, and a byte-wise `" \t"`
604/// trim would be an approximation that diverges on a name padded with U+00A0.
605/// `readDisplayName` hands this string to
606/// `-[NSString stringByTrimmingCharactersInSet:]` with
607/// `+[NSCharacterSet whitespaceCharacterSet]` — the same object
608/// `CharacterSet.whitespaces` bridges to — so the result is Swift's, not an
609/// imitation of it.
610fn joinName(allocator: std.mem.Allocator, given: []const u8, family: []const u8) ![]u8 {
611 return std.fmt.allocPrint(allocator, "{s} {s}", .{ given, family });
612}
613
614// =============================================================================
615// Objective-C. Everything resolved at dispatch, where a failure can still be an
616// ordinary rejection; nothing is looked up inside a completion.
617//
618// Every function below opens with the Darwin guard. That is not decoration: off
619// Darwin `objc_runtime.objc` is an empty struct, and a comptime-false `if` that
620// returns is what stops the rest of the body being analysed at all.
621// =============================================================================
622
623/// The Info.plist key `packages/ios/src/index.ts:189` writes if and only if
624/// `config.enableContacts` — and the key `requestAccessForEntityType:` requires
625/// on pain of process death. See the module comment.
626const key_contacts_usage = "NSContactsUsageDescription";
627
628/// `NSUTF8StringEncoding`. Used only to ask a string how long it really is, so
629/// a NUL-truncated read can be told from a short string.
630const ns_utf8_string_encoding: c_ulong = 4;
631
632/// `CNEntityType.contacts` = 0. `CNEntityType` is `NSInteger`, hence `c_long`;
633/// `bridge_mobile_permissions.zig:505` spells the same constant the same way.
634const cn_entity_type_contacts: c_long = 0;
635
636/// The five keys `CraftApp.swift:3230` fetches, in its order.
637///
638/// Resolved by `dlsym` rather than hardcoded. Their documented *values* are the
639/// KVC names (`"givenName"`, `"identifier"`, ...), so a fallback would be
640/// defensible — but a wrong or missing fetch key does not fail loudly: it
641/// produces a contact whose property access raises
642/// `CNContactPropertyNotFetchedException`, an uncatchable SIGABRT. Refusing
643/// when `dlsym` misses trades a crash for a rejection, and if `CNContactStore`
644/// resolved then Contacts.framework is loaded and these symbols are present.
645const contact_key_symbols = [_][:0]const u8{
646 "CNContactGivenNameKey",
647 "CNContactFamilyNameKey",
648 "CNContactPhoneNumbersKey",
649 "CNContactEmailAddressesKey",
650 "CNContactIdentifierKey",
651};
652
653/// The two labels `addContact` writes. Same `dlsym`-or-refuse policy, and here
654/// the argument is stronger: a wrong label does not fail at all, it saves a
655/// contact whose number is filed under the wrong heading in the user's address
656/// book. Documented values are `"_$!<Main>!$_"` and `"_$!<Home>!$_"`, and
657/// guessing them is exactly what this avoids.
658const label_phone_main = "CNLabelPhoneNumberMain";
659const label_email_home = "CNLabelHome";
660
661fn selector(name: [*:0]const u8) !Id {
662 if (!is_darwin) return error.UnsupportedPlatform;
663 return objc.sel_registerName(name) orelse error.SelectorNotFound;
664}
665
666fn class(name: [*:0]const u8, action: []const u8) !Id {
667 if (!is_darwin) return error.UnsupportedPlatform;
668 return objc.objc_getClass(name) orelse {
669 std.log.err(
670 "{s} refused: this process has no {s}; Contacts.framework is not linked",
671 .{ action, name },
672 );
673 return bridge_error.BridgeError.PlatformNotSupported;
674 };
675}
676
677extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
678
679/// dyld's "search every image" pseudo-handle, `(void *)-2` on Darwin.
680const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
681
682/// One `NSString * const` out of Contacts.framework.
683///
684/// The symbol is the *variable*, so one dereference yields the object; a
685/// resolved symbol holding nil is guarded, because nil in one of these
686/// arguments is a fetch that silently omits a key or a labeled value with no
687/// label.
688fn contactsConstant(symbol: [:0]const u8) !Id {
689 if (!is_darwin) return error.UnsupportedPlatform;
690
691 const sym = dlsym(RTLD_DEFAULT, symbol) orelse {
692 std.log.err(
693 "contacts refused: {s} is not in this process; Contacts.framework is not linked",
694 .{symbol},
695 );
696 return bridge_error.BridgeError.PlatformNotSupported;
697 };
698 const slot: *Id = @ptrCast(@alignCast(sym));
699 return slot.* orelse {
700 std.log.err("contacts refused: {s} resolved to nil", .{symbol});
701 return bridge_error.BridgeError.PlatformNotSupported;
702 };
703}
704
705/// The main bundle's Info.plist value for `key`, or null when it has none.
706///
707/// Errors rather than answering null when the runtime will not cooperate:
708/// "there is no NSBundle" and "this app was not built with contacts enabled"
709/// are different facts, and collapsing them would blame the app's
710/// configuration for a broken process. Same shape as the picker's own gate,
711/// which is private to that file.
712fn infoPlistValue(comptime key: [*:0]const u8) !Id {
713 if (!is_darwin) return error.UnsupportedPlatform;
714
715 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
716 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
717 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
718
719 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
720 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
721 return error.SelectorNotFound;
722 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
723
724 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
725 return error.SelectorNotFound;
726 return objc.msgSendId1(bundle, sel_lookup, ns_key);
727}
728
729/// Refuse before `requestAccessForEntityType:` can take the process down.
730///
731/// Checked first on both actions, matching Swift's `if config.enableContacts`
732/// guarding both cases — and here it is genuinely the framework's precondition
733/// rather than only evidence of the flag. Swift answers such a call with
734/// silence; this answers `PERMISSION_DENIED`.
735fn requireContactsConfigured(action: []const u8) !void {
736 if (!is_darwin) return error.UnsupportedPlatform;
737
738 if ((try infoPlistValue(key_contacts_usage)) == null) {
739 std.log.warn(
740 "{s} refused: Info.plist has no {s}, so this app was not built with contacts " ++
741 "enabled — and requesting access without that key is a TCC process kill",
742 .{ action, key_contacts_usage },
743 );
744 return bridge_error.BridgeError.PermissionDenied;
745 }
746}
747
748/// The one `CNContactStore`, and the selector both actions send it first.
749const Store = struct {
750 object: Id,
751 request_access: Id,
752};
753
754/// Created once for the process, mirroring the single store at
755/// `CraftApp.swift:450`. Never released: it is the process's store, and a
756/// per-call store would re-do Contacts' own setup on every read.
757var store_object: Id = null;
758var store_mutex: compat_mutex.Mutex = .{};
759
760fn contactStore() !Store {
761 if (!is_darwin) return error.UnsupportedPlatform;
762
763 const sel_request = try selector("requestAccessForEntityType:completionHandler:");
764
765 store_mutex.lock();
766 defer store_mutex.unlock();
767
768 if (store_object) |existing| return .{ .object = existing, .request_access = sel_request };
769
770 const StoreClass = try class("CNContactStore", "contacts");
771 const created = (try objc.allocInit(StoreClass)) orelse return error.NativeCallFailed;
772 store_object = created;
773 return .{ .object = created, .request_access = sel_request };
774}
775
776/// `-[CNContactStore requestAccessForEntityType:completionHandler:]` with this
777/// slot's global block. Infallible by the time it is called: the store, the
778/// selector and the ticket are all in hand.
779fn requestAccess(store: Store, ticket: ios_async.Ticket) void {
780 if (!is_darwin) return;
781
782 const Fn = *const fn (Id, Id, c_long, *anyopaque) callconv(.c) void;
783 const func: Fn = @ptrCast(&objc.objc_msgSend);
784 func(store.object, store.request_access, cn_entity_type_contacts, accessBlock(ticket));
785}
786
787/// `UTF8String` and `lengthOfBytesUsingEncoding:`, the pair `readString` needs.
788const TextSels = struct {
789 utf8: Id,
790 length_of_bytes: Id,
791
792 fn resolve() !TextSels {
793 if (!is_darwin) return error.UnsupportedPlatform;
794 return .{
795 .utf8 = try selector("UTF8String"),
796 .length_of_bytes = try selector("lengthOfBytesUsingEncoding:"),
797 };
798 }
799};
800
801/// One `NSString` as bytes, or a refusal.
802///
803/// The truncation check is why this is not one line. `UTF8String` hands back a
804/// NUL-terminated buffer, so a value containing U+0000 — which
805/// `JSONSerialization` would have preserved on the Swift side — reads back as
806/// its prefix, and reporting a prefix as a whole name is the one thing worse
807/// than failing. Inherited unchanged from the picker, divergence included.
808///
809/// The returned slice borrows the string's internal buffer, valid for the
810/// current autorelease pool; every caller copies it into the reply before
811/// returning.
812fn readString(ns: Id, text: TextSels) ![]const u8 {
813 if (!is_darwin) return error.UnsupportedPlatform;
814 if (ns == null) return error.NilNativeString;
815
816 const Utf8Fn = *const fn (Id, Id) callconv(.c) ?[*:0]const u8;
817 const utf8: Utf8Fn = @ptrCast(&objc.objc_msgSend);
818 const cstr = utf8(ns, text.utf8) orelse return error.NativeCallFailed;
819 const bytes = std.mem.span(cstr);
820
821 const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong;
822 const len_of: LenFn = @ptrCast(&objc.objc_msgSend);
823 if (len_of(ns, text.length_of_bytes, ns_utf8_string_encoding) != bytes.len) {
824 return error.EmbeddedNulInNativeString;
825 }
826
827 return bytes;
828}
829
830fn nsString(s: []const u8, allocator: std.mem.Allocator) !Id {
831 if (!is_darwin) return error.UnsupportedPlatform;
832 return (try objc.createNSString(s, allocator)) orelse error.NSStringCreationFailed;
833}
834
835// -----------------------------------------------------------------------------
836// getContacts: the fetch request, and everything the enumeration will need.
837// -----------------------------------------------------------------------------
838
839/// Every selector and class the `getContacts` completion touches, resolved
840/// while a synchronous error can still reach the page.
841///
842/// Deliberately narrower than the picker's `Sels`: no `CNContactFormatter` and
843/// no `+localizedStringForLabel:`, because this action's reply has no localized
844/// labels in it and refusing over a class it never uses would be an over-broad
845/// gate.
846const ReadSels = struct {
847 text: TextSels,
848
849 /// `-[CNContactStore enumerateContactsWithFetchRequest:error:usingBlock:]`.
850 /// Three parts, not two: a two-part spelling registers a selector nothing
851 /// implements and the call goes to `doesNotRecognizeSelector:`.
852 enumerate: Id,
853
854 // NSArray
855 count: Id,
856 object_at: Id,
857
858 // CNContact
859 identifier: Id,
860 given_name: Id,
861 family_name: Id,
862 phone_numbers: Id,
863 email_addresses: Id,
864
865 /// `-[CNLabeledValue value]`. One class for both arrays: the Swift generics
866 /// `CNLabeledValue<CNPhoneNumber>` and `CNLabeledValue<NSString>` are the
867 /// same Objective-C class.
868 value: Id,
869
870 /// `-[CNPhoneNumber stringValue]`, for phones only — see `LabeledKind`.
871 string_value: Id,
872
873 // The displayName trim.
874 character_set_class: Id,
875 whitespace_set: Id,
876 trim: Id,
877
878 fn resolve() !ReadSels {
879 if (!is_darwin) return error.UnsupportedPlatform;
880
881 const NSCharacterSet = objc.objc_getClass("NSCharacterSet") orelse return error.ClassNotFound;
882
883 return .{
884 .text = try TextSels.resolve(),
885 .enumerate = try selector("enumerateContactsWithFetchRequest:error:usingBlock:"),
886
887 .count = try selector("count"),
888 .object_at = try selector("objectAtIndex:"),
889
890 .identifier = try selector("identifier"),
891 .given_name = try selector("givenName"),
892 .family_name = try selector("familyName"),
893 .phone_numbers = try selector("phoneNumbers"),
894 .email_addresses = try selector("emailAddresses"),
895
896 .value = try selector("value"),
897 .string_value = try selector("stringValue"),
898
899 .character_set_class = NSCharacterSet,
900 .whitespace_set = try selector("whitespaceCharacterSet"),
901 .trim = try selector("stringByTrimmingCharactersInSet:"),
902 };
903 }
904};
905
906/// `CNContactFetchRequest(keysToFetch:)` with Swift's five keys and nothing
907/// else set — no predicate, no `unifyResults` change, no sort order, no limit,
908/// because `CraftApp.swift:3231` sets none of them.
909///
910/// Returns +1; the completion releases it.
911fn fetchRequest() !Id {
912 if (!is_darwin) return error.UnsupportedPlatform;
913
914 const NSMutableArray = objc.objc_getClass("NSMutableArray") orelse return error.ClassNotFound;
915 const sel_array = try selector("array");
916 const sel_add = try selector("addObject:");
917 const RequestClass = try class("CNContactFetchRequest", A.get_contacts);
918 const sel_init = try selector("initWithKeysToFetch:");
919
920 const keys = objc.msgSendId(NSMutableArray, sel_array) orelse return error.NativeCallFailed;
921 for (contact_key_symbols) |symbol| {
922 objc.msgSendVoid1(keys, sel_add, try contactsConstant(symbol));
923 }
924
925 // `alloc` then the designated initialiser, never `allocInit` first: `init`
926 // on a `CNContactFetchRequest` is not the initialiser this class takes.
927 // The keys array is autoreleased and the request retains it, so it outlives
928 // this frame's pool exactly as long as the request does.
929 const raw = (try objc.alloc(RequestClass)) orelse return error.NativeCallFailed;
930 return objc.msgSendId1(raw, sel_init, keys) orelse error.NativeCallFailed;
931}
932
933// -----------------------------------------------------------------------------
934// addContact: building the contact, and saving it.
935// -----------------------------------------------------------------------------
936
937/// Everything the `addContact` *dispatch* needs to assemble a
938/// `CNMutableContact` and its save request.
939const BuildSels = struct {
940 mutable_contact_class: Id,
941 set_given_name: Id,
942 set_family_name: Id,
943 set_phone_numbers: Id,
944 set_email_addresses: Id,
945
946 phone_number_class: Id,
947 phone_number_with: Id,
948
949 labeled_value_class: Id,
950 labeled_value_with: Id,
951
952 array_class: Id,
953 array_with_object: Id,
954
955 save_class: Id,
956 save_add: Id,
957
958 fn resolve() !BuildSels {
959 if (!is_darwin) return error.UnsupportedPlatform;
960
961 const NSArray = objc.objc_getClass("NSArray") orelse return error.ClassNotFound;
962
963 return .{
964 .mutable_contact_class = try class("CNMutableContact", A.add_contact),
965 .set_given_name = try selector("setGivenName:"),
966 .set_family_name = try selector("setFamilyName:"),
967 .set_phone_numbers = try selector("setPhoneNumbers:"),
968 .set_email_addresses = try selector("setEmailAddresses:"),
969
970 .phone_number_class = try class("CNPhoneNumber", A.add_contact),
971 .phone_number_with = try selector("phoneNumberWithStringValue:"),
972
973 .labeled_value_class = try class("CNLabeledValue", A.add_contact),
974 .labeled_value_with = try selector("labeledValueWithLabel:value:"),
975
976 .array_class = NSArray,
977 .array_with_object = try selector("arrayWithObject:"),
978
979 .save_class = try class("CNSaveRequest", A.add_contact),
980 .save_add = try selector("addContact:toContainerWithIdentifier:"),
981 };
982 }
983};
984
985/// Everything the `addContact` *completion* needs.
986const SaveSels = struct {
987 text: TextSels,
988 /// `-[CNContactStore executeSaveRequest:error:]`.
989 execute: Id,
990 /// `-[CNContact identifier]`, read after the save, in Swift's order.
991 identifier: Id,
992
993 fn resolve() !SaveSels {
994 if (!is_darwin) return error.UnsupportedPlatform;
995 return .{
996 .text = try TextSels.resolve(),
997 .execute = try selector("executeSaveRequest:error:"),
998 .identifier = try selector("identifier"),
999 };
1000 }
1001};
1002
1003/// `CNMutableContact()` plus Swift's four conditional setters, in Swift's
1004/// order. Returns +1.
1005///
1006/// A field the payload did not carry as a string is not set at all — see
1007/// `contactFieldFrom`. `displayName` is not settable and is not attempted.
1008fn buildContact(allocator: std.mem.Allocator, fields: NewContact, b: BuildSels) !Id {
1009 if (!is_darwin) return error.UnsupportedPlatform;
1010
1011 const contact = (try objc.allocInit(b.mutable_contact_class)) orelse return error.NativeCallFailed;
1012 errdefer objc.release(contact);
1013
1014 if (fields.given_name) |s| {
1015 objc.msgSendVoid1(contact, b.set_given_name, try nsString(s, allocator));
1016 }
1017 if (fields.family_name) |s| {
1018 objc.msgSendVoid1(contact, b.set_family_name, try nsString(s, allocator));
1019 }
1020
1021 if (fields.phone) |s| {
1022 const number = objc.msgSendId1(
1023 b.phone_number_class,
1024 b.phone_number_with,
1025 try nsString(s, allocator),
1026 ) orelse return error.NativeCallFailed;
1027 const labeled = try labeledValue(b, try contactsConstant(label_phone_main), number);
1028 // `phoneNumbers` is a `copy` property, so the autoreleased one-element
1029 // array is retained by the contact and outlives this frame's pool.
1030 const array = objc.msgSendId1(b.array_class, b.array_with_object, labeled) orelse
1031 return error.NativeCallFailed;
1032 objc.msgSendVoid1(contact, b.set_phone_numbers, array);
1033 }
1034
1035 if (fields.email) |s| {
1036 // An email's value is the `NSString` itself — no `CNPhoneNumber`
1037 // wrapper, and no `stringValue` on the way back out either.
1038 const labeled = try labeledValue(
1039 b,
1040 try contactsConstant(label_email_home),
1041 try nsString(s, allocator),
1042 );
1043 const array = objc.msgSendId1(b.array_class, b.array_with_object, labeled) orelse
1044 return error.NativeCallFailed;
1045 objc.msgSendVoid1(contact, b.set_email_addresses, array);
1046 }
1047
1048 return contact;
1049}
1050
1051/// `+[CNLabeledValue labeledValueWithLabel:value:]` — two object arguments, so
1052/// the signature is spelled out rather than left to a one-argument helper.
1053fn labeledValue(b: BuildSels, label: Id, value: Id) !Id {
1054 if (!is_darwin) return error.UnsupportedPlatform;
1055
1056 const Fn = *const fn (Id, Id, Id, Id) callconv(.c) Id;
1057 const func: Fn = @ptrCast(&objc.objc_msgSend);
1058 return func(b.labeled_value_class, b.labeled_value_with, label, value) orelse
1059 error.NativeCallFailed;
1060}
1061
1062/// `CNSaveRequest()` with the contact added to the default container. Returns
1063/// +1.
1064fn buildSaveRequest(contact: Id, b: BuildSels) !Id {
1065 if (!is_darwin) return error.UnsupportedPlatform;
1066
1067 const save = (try objc.allocInit(b.save_class)) orelse return error.NativeCallFailed;
1068 errdefer objc.release(save);
1069
1070 // `toContainerWithIdentifier: nil` — Swift's default container. The
1071 // parameter type is declared, so the bare `null` has a type to take.
1072 const Fn = *const fn (Id, Id, Id, Id) callconv(.c) void;
1073 const func: Fn = @ptrCast(&objc.objc_msgSend);
1074 func(save, b.save_add, contact, null);
1075
1076 return save;
1077}
1078
1079// -----------------------------------------------------------------------------
1080// NSError, for the log only.
1081// -----------------------------------------------------------------------------
1082
1083/// Log an `NSError`'s domain, code and description.
1084///
1085/// This is the only channel `localizedDescription` has: `ios_async` carries a
1086/// `BridgeError` enum, so the text Swift put in the page's error message is
1087/// written to the device log instead of being invented into a reply shape the
1088/// page does not expect. Each lookup is guarded and a miss degrades the line
1089/// rather than the call — nothing here is on the reply path.
1090fn describeNSError(action: []const u8, call: []const u8, ns_error: Id, text: TextSels) void {
1091 if (!is_darwin) return;
1092
1093 std.log.warn("{s}: {s} reported domain={s} code={?d} description={s}", .{
1094 action,
1095 call,
1096 nsErrorString(ns_error, "domain", text) orelse "(none)",
1097 nsErrorCode(ns_error),
1098 nsErrorString(ns_error, "localizedDescription", text) orelse "(none)",
1099 });
1100}
1101
1102fn nsErrorString(ns_error: Id, comptime name: [*:0]const u8, text: TextSels) ?[]const u8 {
1103 if (!is_darwin) return null;
1104
1105 const sel = objc.sel_registerName(name) orelse return null;
1106 const value = objc.msgSendId(ns_error, sel) orelse return null;
1107 return readString(value, text) catch null;
1108}
1109
1110fn nsErrorCode(ns_error: Id) ?c_long {
1111 if (!is_darwin) return null;
1112
1113 const sel = objc.sel_registerName("code") orelse return null;
1114 const Fn = *const fn (Id, Id) callconv(.c) c_long;
1115 const func: Fn = @ptrCast(&objc.objc_msgSend);
1116 return func(ns_error, sel);
1117}
1118
1119// =============================================================================
1120// The per-slot blocks.
1121//
1122// `ios_async.boolErrorBlock` is the right *shape* for
1123// `requestAccessForEntityType:completionHandler:` and the wrong tool: its
1124// invoke replies `"granted"`/`"denied"` immediately, and both of these actions
1125// have to do their real work after the grant and answer with an array or an
1126// id. So each slot gets its own block here — the `bridge_mobile_notifications`
1127// pattern, applied rather than extended.
1128//
1129// A global block captures nothing, which is what makes `Block_copy` on it the
1130// identity function and removes every lifetime question. The price is that a
1131// block knows only its own slot index, baked in at comptime; the ticket, the
1132// selectors and the +1 objects are looked up in the side table below.
1133// =============================================================================
1134
1135const BlockDescriptor = extern struct {
1136 reserved: c_ulong = 0,
1137 size: c_ulong,
1138};
1139
1140/// One layout for both blocks: a global block's literal is the same five words
1141/// whatever its invoke signature is, and only the invoke differs.
1142const Block = extern struct {
1143 isa: ?*anyopaque,
1144 flags: c_int,
1145 reserved: c_int = 0,
1146 invoke: *const anyopaque,
1147 descriptor: *const BlockDescriptor,
1148};
1149
1150/// 1 << 28. A global block is never copied: `Block_copy` returns the same
1151/// pointer. No heap block, no copy/dispose pair, no descriptor lifetime.
1152const BLOCK_IS_GLOBAL: c_int = 1 << 28;
1153
1154const block_descriptor = BlockDescriptor{ .size = @sizeOf(Block) };
1155
1156extern var _NSConcreteGlobalBlock: anyopaque;
1157
1158/// `void (^)(BOOL granted, NSError * _Nullable error)`.
1159fn makeAccessInvoke(comptime index: u5) *const anyopaque {
1160 const S = struct {
1161 fn invoke(_: *const Block, granted: bool, err: Id) callconv(.c) void {
1162 accessCompletionFired(index, granted, err);
1163 }
1164 };
1165 return @ptrCast(&S.invoke);
1166}
1167
1168/// `void (^)(CNContact * _Nonnull contact, BOOL * _Nonnull stop)`.
1169///
1170/// **Returns void.** A `bool`-returning invoke would register and run and read
1171/// its result out of a register nobody wrote — the same silent class of bug as
1172/// a wrong method type encoding, with no crash and no compile error to say so.
1173fn makeEnumerationInvoke(comptime index: u5) *const anyopaque {
1174 const S = struct {
1175 fn invoke(_: *const Block, contact: Id, stop: *bool) callconv(.c) void {
1176 enumerationFired(index, contact, stop);
1177 }
1178 };
1179 return @ptrCast(&S.invoke);
1180}
1181
1182fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_in_flight]Block {
1183 var out: [ios_async.max_in_flight]Block = undefined;
1184 for (&out, 0..) |*b, i| {
1185 b.* = .{
1186 .isa = &_NSConcreteGlobalBlock,
1187 .flags = BLOCK_IS_GLOBAL,
1188 .invoke = maker(@intCast(i)),
1189 .descriptor = &block_descriptor,
1190 };
1191 }
1192 return out;
1193}
1194
1195var access_blocks: [ios_async.max_in_flight]Block =
1196 if (is_darwin) makeBlocks(makeAccessInvoke) else undefined;
1197var enumeration_blocks: [ios_async.max_in_flight]Block =
1198 if (is_darwin) makeBlocks(makeEnumerationInvoke) else undefined;
1199
1200fn accessBlock(ticket: ios_async.Ticket) *anyopaque {
1201 return @ptrCast(&access_blocks[ticket.index]);
1202}
1203
1204fn enumerationBlock(index: u5) *anyopaque {
1205 return @ptrCast(&enumeration_blocks[index]);
1206}
1207
1208// =============================================================================
1209// The side table: one entry per async slot.
1210//
1211// One entry per slot rather than the picker's single `pending`, because nothing
1212// here is exclusive — two reads of the address book are two reads, and refusing
1213// the second would refuse a call the shim serves. The slot is leased
1214// exclusively by its ticket, so a publish can never displace a live call.
1215// =============================================================================
1216
1217/// What the completion has to finish and free, per action.
1218const GetWork = struct {
1219 /// +1 `CNContactFetchRequest`. Released by the completion on every path.
1220 request: Id,
1221 sels: ReadSels,
1222};
1223
1224const AddWork = struct {
1225 /// +1 `CNMutableContact`, built at dispatch.
1226 contact: Id,
1227 /// +1 `CNSaveRequest`, holding the contact.
1228 save: Id,
1229 sels: SaveSels,
1230};
1231
1232const Work = union(Route) {
1233 get_contacts: GetWork,
1234 add_contact: AddWork,
1235};
1236
1237const PendingCall = struct {
1238 ticket: ios_async.Ticket,
1239 store: Id,
1240 work: Work,
1241};
1242
1243var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
1244var pending_mutex: compat_mutex.Mutex = .{};
1245
1246fn publishPendingCall(call: PendingCall) void {
1247 pending_mutex.lock();
1248 defer pending_mutex.unlock();
1249 pending_calls[call.ticket.index] = call;
1250}
1251
1252/// Read and clear. Clearing is what makes a second fire of one completion a
1253/// no-op rather than a second reply to a promise that has already settled —
1254/// and, for the +1 objects, what stops a double release.
1255fn takePendingCall(index: u5) ?PendingCall {
1256 pending_mutex.lock();
1257 defer pending_mutex.unlock();
1258 const call = pending_calls[index];
1259 pending_calls[index] = null;
1260 return call;
1261}
1262
1263// =============================================================================
1264// The enumeration's state.
1265//
1266// `enumerateContactsWithFetchRequest:error:usingBlock:` is synchronous: the
1267// block runs on the calling thread and the call returns when the walk ends. So
1268// this is a pointer to `collectContacts`'s frame, which provably outlives every
1269// fire, published for the duration of that one call and cleared immediately
1270// after. The mutex is held anyway — the completion that owns the frame runs on
1271// whatever queue Contacts chose, and slots are independent.
1272// =============================================================================
1273
1274const Collector = struct {
1275 allocator: std.mem.Allocator,
1276 sels: ReadSels,
1277 /// `+[NSCharacterSet whitespaceCharacterSet]`, read once per walk rather
1278 /// than once per contact.
1279 whitespace: Id,
1280 writer: *RowWriter,
1281 /// The first failure, kept so the walk can stop and the call can reject
1282 /// instead of resolving a partial address book as if it were complete.
1283 failure: ?anyerror = null,
1284};
1285
1286var collectors: [ios_async.max_in_flight]?*Collector = @splat(null);
1287var collectors_mutex: compat_mutex.Mutex = .{};
1288
1289fn publishCollector(index: u5, collector: *Collector) void {
1290 collectors_mutex.lock();
1291 defer collectors_mutex.unlock();
1292 collectors[index] = collector;
1293}
1294
1295fn collectorFor(index: u5) ?*Collector {
1296 collectors_mutex.lock();
1297 defer collectors_mutex.unlock();
1298 return collectors[index];
1299}
1300
1301fn clearCollector(index: u5) void {
1302 collectors_mutex.lock();
1303 defer collectors_mutex.unlock();
1304 collectors[index] = null;
1305}
1306
1307// =============================================================================
1308// The completions. None replies directly: `evaluateJavaScript:` is
1309// main-thread-only *and* the `request_context` naming the waiting call is long
1310// gone, so every answer goes through `ios_async`, which hops to the main queue
1311// and replies under the id captured at dispatch.
1312//
1313// Plain `fn`, never `export`: `@ptrCast(&f)` works either way and an exported
1314// name could collide with a desktop module in the same host-test binary.
1315// =============================================================================
1316
1317fn accessCompletionFired(index: u5, granted: bool, err: Id) void {
1318 if (!is_darwin) return;
1319
1320 const call = takePendingCall(index) orelse {
1321 std.log.warn(
1322 "contacts: an access completion fired for slot {d} with no call recorded; " ++
1323 "ignored rather than answered to whoever holds the slot next",
1324 .{index},
1325 );
1326 return;
1327 };
1328
1329 const allocator = std.heap.c_allocator;
1330
1331 switch (call.work) {
1332 .get_contacts => |work| {
1333 defer objc.release(work.request);
1334
1335 if (!granted) return denied(call.ticket, A.get_contacts, err, work.sels.text);
1336
1337 const json = collectContacts(allocator, call.store, work, call.ticket.index) catch |e| {
1338 std.log.err(
1339 "getContacts: the address book could not be read ({}); rejecting rather " ++
1340 "than replying with a list that was not read",
1341 .{e},
1342 );
1343 ios_async.deliverErrorCode(call.ticket, failureCode(e));
1344 return;
1345 };
1346 defer allocator.free(json);
1347
1348 // `[]` for an empty book, and it resolves. See `RowWriter.finish`.
1349 ios_async.deliverJson(call.ticket, json);
1350 },
1351 .add_contact => |work| {
1352 // Release order mirrors construction: the save request holds the
1353 // contact, so it goes first.
1354 defer objc.release(work.contact);
1355 defer objc.release(work.save);
1356
1357 if (!granted) return denied(call.ticket, A.add_contact, err, work.sels.text);
1358
1359 const json = saveContact(allocator, call.store, work) catch |e| {
1360 std.log.err(
1361 "addContact: the contact was not saved ({}); rejecting rather than " ++
1362 "replying with an identifier for a contact that does not exist",
1363 .{e},
1364 );
1365 ios_async.deliverErrorCode(call.ticket, failureCode(e));
1366 return;
1367 };
1368 defer allocator.free(json);
1369
1370 ios_async.deliverJson(call.ticket, json);
1371 },
1372 }
1373}
1374
1375/// The user declined the prompt — or TCC had already declined it for them.
1376///
1377/// `PermissionDenied`, never the generic failure: the call worked perfectly and
1378/// the answer is "no". Reporting `NATIVE_CALL_FAILED` would send whoever reads
1379/// the error hunting a bug that is not there. Swift's code and message differ;
1380/// the module comment says how.
1381fn denied(ticket: ios_async.Ticket, action: []const u8, err: Id, text: TextSels) void {
1382 if (!is_darwin) return;
1383
1384 // A declined prompt carries no NSError at all, which is normal and not
1385 // worth a line; an error object means something else happened and is.
1386 if (err != null) describeNSError(action, "requestAccessForEntityType:", err, text);
1387
1388 std.log.info(
1389 "{s}: contacts access was not granted; rejecting as PERMISSION_DENIED " ++
1390 "(Swift sends CRAFT_ERROR with the NSError's description)",
1391 .{action},
1392 );
1393 ios_async.deliverErrorCode(ticket, bridge_error.BridgeError.PermissionDenied);
1394}
1395
1396/// Which `BridgeError` a completion-side failure reports.
1397///
1398/// Only three of these are honest distinctions, so only three are made: a
1399/// declined permission never reaches here, an allocation failure is one, and
1400/// everything else genuinely is a native call that did not produce an answer.
1401fn failureCode(err: anyerror) bridge_error.BridgeError {
1402 return switch (err) {
1403 error.OutOfMemory, error.AllocationFailed => bridge_error.BridgeError.AllocationFailed,
1404 error.PermissionDenied => bridge_error.BridgeError.PermissionDenied,
1405 else => bridge_error.BridgeError.NativeCallFailed,
1406 };
1407}
1408
1409/// Walk the whole address book into the reply array.
1410///
1411/// No predicate, no sort, no limit — `CraftApp.swift:3235` passes none, and
1412/// adding one would answer a different question than the page asked.
1413fn collectContacts(
1414 allocator: std.mem.Allocator,
1415 store: Id,
1416 work: GetWork,
1417 index: u5,
1418) ![]u8 {
1419 if (!is_darwin) return error.UnsupportedPlatform;
1420
1421 const whitespace = objc.msgSendId(work.sels.character_set_class, work.sels.whitespace_set) orelse
1422 return error.NativeCallFailed;
1423
1424 var writer = RowWriter{};
1425 errdefer writer.deinit(allocator);
1426 try writer.begin(allocator);
1427
1428 var collector = Collector{
1429 .allocator = allocator,
1430 .sels = work.sels,
1431 .whitespace = whitespace,
1432 .writer = &writer,
1433 };
1434
1435 publishCollector(index, &collector);
1436 defer clearCollector(index);
1437
1438 var err_out: Id = null;
1439 const EnumFn = *const fn (Id, Id, Id, ?*Id, *anyopaque) callconv(.c) bool;
1440 const enumerate: EnumFn = @ptrCast(&objc.objc_msgSend);
1441 const ok = enumerate(store, work.sels.enumerate, work.request, &err_out, enumerationBlock(index));
1442
1443 // The collector's failure is checked first and on purpose: a block that
1444 // stopped the walk early leaves `ok` true, and returning the partial array
1445 // would report a truncated address book as a complete one.
1446 if (collector.failure) |failure| return failure;
1447
1448 if (!ok) {
1449 if (err_out != null) {
1450 describeNSError(
1451 A.get_contacts,
1452 "enumerateContactsWithFetchRequest:error:usingBlock:",
1453 err_out,
1454 work.sels.text,
1455 );
1456 } else {
1457 std.log.warn(
1458 "getContacts: enumerateContactsWithFetchRequest: returned NO with no NSError",
1459 .{},
1460 );
1461 }
1462 return error.NativeCallFailed;
1463 }
1464
1465 return writer.finish(allocator);
1466}
1467
1468/// One `CNContact`, straight into the reply.
1469///
1470/// Runs on the thread that called `enumerateContacts...`, inside
1471/// `collectContacts`'s frame, which is why the collector pointer is valid.
1472///
1473/// `stop` is what Swift discards (`{ contact, _ in }`) and this file uses: on a
1474/// failure the walk stops instead of reading the rest of an address book whose
1475/// answer has already been given up on. That is a deliberate improvement over
1476/// the shim, not parity — Swift's `contacts.append` would trap rather than
1477/// unwind — and the reply is a rejection either way.
1478fn enumerationFired(index: u5, contact: Id, stop: *bool) void {
1479 if (!is_darwin) return;
1480
1481 const collector = collectorFor(index) orelse {
1482 // No live walk owns this slot's writer, so nothing is being truncated
1483 // by stopping — there is simply nowhere to put a contact.
1484 std.log.warn(
1485 "getContacts: enumeration fired for slot {d} with no collector; stopping the walk",
1486 .{index},
1487 );
1488 stop.* = true;
1489 return;
1490 };
1491
1492 if (collector.failure != null) {
1493 stop.* = true;
1494 return;
1495 }
1496
1497 appendContact(collector, contact) catch |err| {
1498 collector.failure = err;
1499 stop.* = true;
1500 };
1501}
1502
1503fn appendContact(collector: *Collector, contact: Id) !void {
1504 if (!is_darwin) return error.UnsupportedPlatform;
1505 // The block parameter is declared `_Nonnull`; a nil here would mean the
1506 // framework broke its own contract, and "a contact with no fields" is a
1507 // claim there would be no basis for.
1508 if (contact == null) return error.NativeCallFailed;
1509
1510 const sels = collector.sels;
1511 const allocator = collector.allocator;
1512
1513 const given = try readString(objc.msgSendId(contact, sels.given_name), sels.text);
1514 const family = try readString(objc.msgSendId(contact, sels.family_name), sels.text);
1515 const identifier = try readString(objc.msgSendId(contact, sels.identifier), sels.text);
1516 const display = try readDisplayName(allocator, given, family, sels, collector.whitespace);
1517
1518 const phones = try readValueStrings(
1519 allocator,
1520 objc.msgSendId(contact, sels.phone_numbers),
1521 sels,
1522 .phone,
1523 );
1524 defer allocator.free(phones);
1525
1526 const emails = try readValueStrings(
1527 allocator,
1528 objc.msgSendId(contact, sels.email_addresses),
1529 sels,
1530 .email,
1531 );
1532 defer allocator.free(emails);
1533
1534 try collector.writer.append(allocator, .{
1535 .id = identifier,
1536 .given_name = given,
1537 .family_name = family,
1538 .display_name = display,
1539 .phones = phones,
1540 .emails = emails,
1541 });
1542}
1543
1544/// `"\(givenName) \(familyName)".trimmingCharacters(in: .whitespaces)`, done by
1545/// Foundation rather than imitated. See `joinName`.
1546///
1547/// `given` and `family` have already been through `readString`, so neither can
1548/// contain the NUL that would truncate the joined string on the way into
1549/// `stringWithUTF8String:`.
1550fn readDisplayName(
1551 allocator: std.mem.Allocator,
1552 given: []const u8,
1553 family: []const u8,
1554 sels: ReadSels,
1555 whitespace: Id,
1556) ![]const u8 {
1557 if (!is_darwin) return error.UnsupportedPlatform;
1558
1559 const joined = try joinName(allocator, given, family);
1560 defer allocator.free(joined);
1561
1562 const ns = try nsString(joined, allocator);
1563 const trimmed = objc.msgSendId1(ns, sels.trim, whitespace) orelse return error.NativeCallFailed;
1564 return readString(trimmed, sels.text);
1565}
1566
1567/// Which kind of `CNLabeledValue` array is being walked.
1568///
1569/// The two differ in exactly one step: a phone's `value` is a `CNPhoneNumber`
1570/// and needs `stringValue`, while an email's `value` already *is* an
1571/// `NSString`. Sending `stringValue` to an `NSString` is an unrecognised
1572/// selector — a SIGABRT, not an error to map — so this is a comptime enum
1573/// rather than an optional selector that could arrive null.
1574const LabeledKind = enum { phone, email };
1575
1576/// `NSArray<CNLabeledValue *>` into flat strings.
1577///
1578/// No labels are read at all: `getContacts` throws them away, so the picker's
1579/// `+[CNLabeledValue localizedStringForLabel:]` per value — across an entire
1580/// address book — would be work done for nothing.
1581fn readValueStrings(
1582 allocator: std.mem.Allocator,
1583 array: Id,
1584 sels: ReadSels,
1585 comptime kind: LabeledKind,
1586) ![][]const u8 {
1587 if (!is_darwin) return error.UnsupportedPlatform;
1588 // Both properties are declared non-null and answer an empty array for a
1589 // contact with none.
1590 if (array == null) return error.NativeCallFailed;
1591
1592 const CountFn = *const fn (Id, Id) callconv(.c) c_ulong;
1593 const count_of: CountFn = @ptrCast(&objc.objc_msgSend);
1594 const total = count_of(array, sels.count);
1595
1596 var out: std.ArrayListUnmanaged([]const u8) = .empty;
1597 errdefer out.deinit(allocator);
1598
1599 var i: c_ulong = 0;
1600 while (i < total) : (i += 1) {
1601 const entry = objc.msgSendId1(array, sels.object_at, i) orelse return error.NativeCallFailed;
1602 const raw = objc.msgSendId(entry, sels.value) orelse return error.NativeCallFailed;
1603 const ns = switch (kind) {
1604 .phone => objc.msgSendId(raw, sels.string_value) orelse return error.NativeCallFailed,
1605 .email => raw,
1606 };
1607 try out.append(allocator, try readString(ns, sels.text));
1608 }
1609
1610 return out.toOwnedSlice(allocator);
1611}
1612
1613/// `try store.execute(saveRequest)` then `contact.identifier`, in Swift's
1614/// order: the identifier is only meaningful once the save has succeeded, and
1615/// reading it first would put a real-looking id on a contact that was never
1616/// written.
1617fn saveContact(allocator: std.mem.Allocator, store: Id, work: AddWork) ![]u8 {
1618 if (!is_darwin) return error.UnsupportedPlatform;
1619
1620 var err_out: Id = null;
1621 const ExecFn = *const fn (Id, Id, Id, ?*Id) callconv(.c) bool;
1622 const execute: ExecFn = @ptrCast(&objc.objc_msgSend);
1623
1624 if (!execute(store, work.sels.execute, work.save, &err_out)) {
1625 if (err_out != null) {
1626 describeNSError(A.add_contact, "executeSaveRequest:error:", err_out, work.sels.text);
1627 } else {
1628 std.log.warn("addContact: executeSaveRequest: returned NO with no NSError", .{});
1629 }
1630 return error.NativeCallFailed;
1631 }
1632
1633 const identifier = try readString(
1634 objc.msgSendId(work.contact, work.sels.identifier),
1635 work.sels.text,
1636 );
1637 return shapeIdentifier(allocator, identifier);
1638}
1639
1640// =============================================================================
1641// Tests — host-only.
1642//
1643// Everything that decides page-visible bytes is pure and pinned here: routing
1644// in both directions, the `as? String` rule, the six keys and their order, the
1645// flat string arrays that distinguish this reply from the picker's, the bare
1646// identifier string, the empty-book resolve, escaping, and the slot
1647// bookkeeping.
1648//
1649// Nothing here touches `CNContactStore`. On a developer's Mac those classes all
1650// exist and `requestAccessForEntityType:` would put a real TCC prompt on
1651// someone's screen — so no test calls `handleMessage` with a payload that would
1652// reach the native path, and the two that do call it use bodies that are
1653// refused during parsing, which is itself the assertion that parsing comes
1654// first.
1655// =============================================================================
1656
1657const testing = std.testing;
1658
1659fn resetSlotsForTesting() void {
1660 pending_mutex.lock();
1661 pending_calls = @splat(null);
1662 pending_mutex.unlock();
1663
1664 collectors_mutex.lock();
1665 collectors = @splat(null);
1666 collectors_mutex.unlock();
1667}
1668
1669fn fakeTicket(index: u5, generation: u32) ios_async.Ticket {
1670 return .{ .index = index, .generation = generation };
1671}
1672
1673fn fakeGetWork() Work {
1674 // Every field is a plain `Id`; nothing below dereferences them, and the
1675 // bookkeeping under test does not care what they point at.
1676 return .{ .get_contacts = .{ .request = null, .sels = std.mem.zeroes(ReadSels) } };
1677}
1678
1679// ---------------------------------------------------------------------------
1680// The action table, against the dispatcher, in both directions
1681// ---------------------------------------------------------------------------
1682
1683test "the declared actions are the ones the handler serves" {
1684 try testing.expectEqual(@as(usize, 2), capability_actions.len);
1685 try testing.expectEqualStrings(A.get_contacts, capability_actions[0].name);
1686 try testing.expectEqualStrings(A.add_contact, capability_actions[1].name);
1687
1688 for (capability_actions) |decl| {
1689 // A `.result` whose handler never replies parks the caller on a promise
1690 // with no timeout; a `.none` that is awaited resolves immediately and
1691 // means nothing. Swift resolves or rejects every path, so `.result`.
1692 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1693 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1694 // `.live` with a reason would be a contradiction the manifest shows apps.
1695 try testing.expect(decl.reason == null);
1696 }
1697}
1698
1699test "the action names match the Swift case labels exactly" {
1700 // The conformance ratchet compares this against the `case "…":` labels in
1701 // `CraftApp.swift` in both directions; a prettier spelling would read as Zig
1702 // serving an action the spec does not have. It cannot catch both sides
1703 // being renamed in step, which this holds.
1704 try testing.expectEqualStrings("getContacts", A.get_contacts);
1705 try testing.expectEqualStrings("addContact", A.add_contact);
1706}
1707
1708test "every declared action is one the dispatcher routes" {
1709 for (capability_actions) |decl| {
1710 if (routeFor(decl.name) == null) {
1711 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1712 return error.DeclaredActionDoesNotRoute;
1713 }
1714 }
1715}
1716
1717test "every route the dispatcher has is a declared action" {
1718 // The other direction: a route with a handler and no declaration is an
1719 // action the page can call and the manifest denies exists. Each declaration
1720 // must claim a *distinct* route — counting alone would let two rows share
1721 // one route while another went undeclared.
1722 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
1723 for (capability_actions) |decl| {
1724 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1725 const slot = @backingInt(route);
1726 if (claimed[slot]) {
1727 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1728 return error.TwoDeclarationsShareARoute;
1729 }
1730 claimed[slot] = true;
1731 }
1732 for (claimed, 0..) |taken, slot| {
1733 if (!taken) {
1734 std.debug.print(
1735 "route {s} has a handler but no capability_actions row\n",
1736 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
1737 );
1738 return error.RouteNotDeclared;
1739 }
1740 }
1741}
1742
1743test "both actions route to their own handler" {
1744 try testing.expectEqual(Route.get_contacts, routeFor("getContacts").?);
1745 try testing.expectEqual(Route.add_contact, routeFor("addContact").?);
1746}
1747
1748test "an action this module does not serve is refused as UnknownAction" {
1749 // Not any other error: `ios_dispatch.route` reads UnknownAction as "not
1750 // mine, ask the next module" and anything else as a final answer. Getting
1751 // this wrong would make this file swallow another module's action — or the
1752 // shim's.
1753 var bridge = ContactsBridge.init(testing.allocator);
1754 defer bridge.deinit();
1755
1756 for ([_][]const u8{
1757 // The picker's action, which is a different module and a different
1758 // reply shape.
1759 "pickContact",
1760 "getCalendarEvents",
1761 "requestPermission",
1762 // Casing and plurals are how a real typo arrives, and a miss does not
1763 // fail loudly: the action would quietly fall through to the Swift shim.
1764 "getcontacts",
1765 "GetContacts",
1766 "getContact",
1767 "addContacts",
1768 "addcontact",
1769 "",
1770 }) |action| {
1771 try testing.expectError(
1772 bridge_error.BridgeError.UnknownAction,
1773 bridge.handleMessage(action, "{}"),
1774 );
1775 try testing.expect(routeFor(action) == null);
1776 }
1777}
1778
1779test "getContacts cannot fail on a payload it never reads" {
1780 // Swift's case reads nothing off `body`, so there is no field to drop and
1781 // no malformed `d` that may fail this call. The handler taking no `data`
1782 // parameter is what makes that structural rather than a promise: `self`
1783 // only, where `addContact` takes `self` and the payload.
1784 try testing.expectEqual(
1785 @as(usize, 1),
1786 @typeInfo(@TypeOf(ContactsBridge.getContacts)).@"fn".param_types.len,
1787 );
1788 try testing.expectEqual(
1789 @as(usize, 2),
1790 @typeInfo(@TypeOf(ContactsBridge.addContact)).@"fn".param_types.len,
1791 );
1792}
1793
1794// ---------------------------------------------------------------------------
1795// addContact's payload
1796// ---------------------------------------------------------------------------
1797
1798test "the four fields are read out of the nested contact object" {
1799 // The page posts `{action, contact: {...}, callbackId}`, so `d`'s root has
1800 // a `contact` key whose value is an object. Reading these off the root
1801 // instead would silently save an empty contact.
1802 var fields = try parseNewContact(testing.allocator,
1803 \\{"contact":{"givenName":"Ada","familyName":"Lovelace","phone":"+1 555 0100","email":"ada@example.com"}}
1804 );
1805 defer fields.deinit(testing.allocator);
1806
1807 try testing.expectEqualStrings("Ada", fields.given_name.?);
1808 try testing.expectEqualStrings("Lovelace", fields.family_name.?);
1809 try testing.expectEqualStrings("+1 555 0100", fields.phone.?);
1810 try testing.expectEqualStrings("ada@example.com", fields.email.?);
1811}
1812
1813test "an absent field is unset rather than empty" {
1814 // Unset and empty-string are different contacts: `givenName = ""` writes an
1815 // empty name over whatever the container would have derived.
1816 var fields = try parseNewContact(testing.allocator, "{\"contact\":{\"givenName\":\"Solo\"}}");
1817 defer fields.deinit(testing.allocator);
1818
1819 try testing.expectEqualStrings("Solo", fields.given_name.?);
1820 try testing.expect(fields.family_name == null);
1821 try testing.expect(fields.phone == null);
1822 try testing.expect(fields.email == null);
1823}
1824
1825test "a field that is not a string is left unset, exactly as as? String leaves it" {
1826 // `{"phone": 5551234}` is the case that decides this file's behaviour:
1827 // Swift's `if let phone = data["phone"] as? String` does not fire, the
1828 // property is not set, and the save proceeds. Erroring would refuse a call
1829 // the shim accepts; stringifying would write a number the page never sent
1830 // as text.
1831 var fields = try parseNewContact(testing.allocator,
1832 \\{"contact":{"givenName":5551234,"familyName":true,"phone":null,"email":{"a":1}}}
1833 );
1834 defer fields.deinit(testing.allocator);
1835
1836 try testing.expect(fields.given_name == null);
1837 try testing.expect(fields.family_name == null);
1838 try testing.expect(fields.phone == null);
1839 try testing.expect(fields.email == null);
1840}
1841
1842test "displayName is dropped, because CNMutableContact has nowhere to put it" {
1843 // `craft.d.ts` declares it and `test-bridges.html` sends it; Swift reads
1844 // four keys and this is not one of them. Parity, stated in a test so the
1845 // next reader does not "fix" it.
1846 var fields = try parseNewContact(
1847 testing.allocator,
1848 "{\"contact\":{\"displayName\":\"Test User\"}}",
1849 );
1850 defer fields.deinit(testing.allocator);
1851
1852 try testing.expect(fields.given_name == null);
1853 try testing.expect(fields.family_name == null);
1854 try testing.expect(fields.phone == null);
1855 try testing.expect(fields.email == null);
1856 const field_names = @typeInfo(NewContact).@"struct".field_names;
1857 try testing.expectEqual(@as(usize, 4), field_names.len);
1858 for (field_names) |name| {
1859 try testing.expect(!std.mem.eql(u8, name, "display_name"));
1860 try testing.expect(!std.mem.eql(u8, name, "displayName"));
1861 }
1862}
1863
1864test "a missing or non-object contact rejects, where Swift answers nothing at all" {
1865 try testing.expectError(
1866 bridge_error.BridgeError.MissingData,
1867 parseNewContact(testing.allocator, "{}"),
1868 );
1869 try testing.expectError(
1870 bridge_error.BridgeError.MissingData,
1871 parseNewContact(testing.allocator, "{\"other\":1}"),
1872 );
1873 try testing.expectError(
1874 bridge_error.BridgeError.InvalidParameter,
1875 parseNewContact(testing.allocator, "{\"contact\":\"Ada\"}"),
1876 );
1877 try testing.expectError(
1878 bridge_error.BridgeError.InvalidParameter,
1879 parseNewContact(testing.allocator, "{\"contact\":null}"),
1880 );
1881 try testing.expectError(
1882 bridge_error.BridgeError.InvalidParameter,
1883 parseNewContact(testing.allocator, "{\"contact\":[{\"givenName\":\"Ada\"}]}"),
1884 );
1885}
1886
1887test "the payload is validated before anything native is touched" {
1888 // The refusal has to reach the page identically on every platform, and it
1889 // has to happen before `requestAccessForEntityType:` — which in an app
1890 // without NSContactsUsageDescription is a process kill, and in one with it
1891 // is a prompt on a user's screen for a call that was already malformed.
1892 // This is also why the suite can run these at all.
1893 var bridge = ContactsBridge.init(testing.allocator);
1894 defer bridge.deinit();
1895
1896 try testing.expectError(
1897 bridge_error.BridgeError.MissingData,
1898 bridge.handleMessage(A.add_contact, "{}"),
1899 );
1900 try testing.expectError(
1901 bridge_error.BridgeError.InvalidJSON,
1902 bridge.handleMessage(A.add_contact, "{\"contact\":"),
1903 );
1904}
1905
1906test "a body that cannot be read is an error, not an empty contact" {
1907 // Acting on a default for a payload that failed to parse is how an empty
1908 // contact gets written to somebody's address book and reported as success.
1909 try testing.expectError(
1910 bridge_error.BridgeError.InvalidJSON,
1911 parseNewContact(testing.allocator, "{\"contact\":{"),
1912 );
1913 try testing.expectError(
1914 bridge_error.BridgeError.InvalidJSON,
1915 parseNewContact(testing.allocator, "[{\"givenName\":\"Ada\"}]"),
1916 );
1917 try testing.expectError(
1918 bridge_error.BridgeError.InvalidJSON,
1919 parseNewContact(testing.allocator, "\"contact\""),
1920 );
1921}
1922
1923test "an embedded NUL is refused rather than silently truncated" {
1924 // `stringWithUTF8String:` stops at the first NUL, so accepting this would
1925 // write "Ada" where the page sent "Ada\x00Lovelace" — a wrong value in the
1926 // user's address book, reported as success. Swift would have stored the
1927 // whole string; the divergence is the module comment's, and this is where
1928 // it is enforced.
1929 // `\u0000` in the JSON text, which is how a NUL legitimately arrives:
1930 // `JSON.stringify` emits exactly this escape and the parser decodes it to a
1931 // real NUL byte in the value.
1932 try testing.expectError(
1933 bridge_error.BridgeError.InvalidParameter,
1934 parseNewContact(testing.allocator, "{\"contact\":{\"givenName\":\"Ada\\u0000Lovelace\"}}"),
1935 );
1936 try testing.expectError(
1937 bridge_error.BridgeError.InvalidParameter,
1938 parseNewContact(testing.allocator, "{\"contact\":{\"email\":\"a\\u0000b@example.com\"}}"),
1939 );
1940
1941 // A raw NUL *byte* in the message is a different fault — an unescaped
1942 // control character is not JSON at all — and is reported as such rather
1943 // than as a bad field.
1944 try testing.expectError(
1945 bridge_error.BridgeError.InvalidJSON,
1946 parseNewContact(testing.allocator, "{\"contact\":{\"givenName\":\"Ada\u{0000}Lovelace\"}}"),
1947 );
1948}
1949
1950// ---------------------------------------------------------------------------
1951// getContacts' reply
1952// ---------------------------------------------------------------------------
1953
1954fn shapeRowsForTesting(allocator: std.mem.Allocator, rows: []const ContactRow) ![]u8 {
1955 var writer = RowWriter{};
1956 errdefer writer.deinit(allocator);
1957 try writer.begin(allocator);
1958 for (rows) |row| try writer.append(allocator, row);
1959 return writer.finish(allocator);
1960}
1961
1962test "one contact is the six-key object getContacts builds, in a fixed order" {
1963 const allocator = testing.allocator;
1964 const json = try shapeRowsForTesting(allocator, &.{.{
1965 .id = "AB12-CD34",
1966 .given_name = "Ada",
1967 .family_name = "Lovelace",
1968 .display_name = "Ada Lovelace",
1969 .phones = &.{"+1 555 0100"},
1970 .emails = &.{"ada@example.com"},
1971 }});
1972 defer allocator.free(json);
1973
1974 try testing.expectEqualStrings(
1975 "[{\"id\":\"AB12-CD34\",\"givenName\":\"Ada\",\"familyName\":\"Lovelace\"," ++
1976 "\"displayName\":\"Ada Lovelace\"," ++
1977 "\"phoneNumbers\":[\"+1 555 0100\"]," ++
1978 "\"emailAddresses\":[\"ada@example.com\"]}]",
1979 json,
1980 );
1981}
1982
1983test "phone numbers and email addresses are flat strings, not the picker's label pairs" {
1984 // The single easiest thing to get wrong here, because the sibling module
1985 // does the opposite and both parse. `getContacts` is
1986 // `phone.value.stringValue` into `[String]`; `pickContact` is
1987 // `{label, number}`. `craft.d.ts:1157-1164` agrees with this one.
1988 const allocator = testing.allocator;
1989 const json = try shapeRowsForTesting(allocator, &.{.{
1990 .id = "1",
1991 .given_name = "A",
1992 .family_name = "B",
1993 .display_name = "A B",
1994 .phones = &.{ "555", "556" },
1995 .emails = &.{"a@b.c"},
1996 }});
1997 defer allocator.free(json);
1998
1999 try testing.expect(std.mem.indexOf(u8, json, "\"label\"") == null);
2000 try testing.expect(std.mem.indexOf(u8, json, "\"number\"") == null);
2001 try testing.expect(std.mem.indexOf(u8, json, "\"address\"") == null);
2002
2003 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2004 defer parsed.deinit();
2005
2006 const one = parsed.value.array.items[0].object;
2007 const phones = one.get("phoneNumbers").?.array.items;
2008 try testing.expectEqual(@as(usize, 2), phones.len);
2009 try testing.expectEqualStrings("555", phones[0].string);
2010 try testing.expectEqualStrings("556", phones[1].string);
2011 try testing.expectEqualStrings("a@b.c", one.get("emailAddresses").?.array.items[0].string);
2012}
2013
2014test "a contact with no numbers and no addresses keeps both keys as empty arrays" {
2015 // Swift always assigns both keys, so a page reading
2016 // `contact.phoneNumbers.length` must not meet `undefined`.
2017 const allocator = testing.allocator;
2018 const json = try shapeRowsForTesting(allocator, &.{.{
2019 .id = "1",
2020 .given_name = "Solo",
2021 .family_name = "",
2022 .display_name = "Solo",
2023 .phones = &.{},
2024 .emails = &.{},
2025 }});
2026 defer allocator.free(json);
2027
2028 try testing.expect(std.mem.indexOf(u8, json, "\"phoneNumbers\":[]") != null);
2029 try testing.expect(std.mem.indexOf(u8, json, "\"emailAddresses\":[]") != null);
2030}
2031
2032test "an empty address book is an empty array that resolves" {
2033 // A fresh Simulator answers exactly this, and `test-bridges.html:804`
2034 // prints `Found 0 contacts`. Turning it into an error or a `{}` would fail
2035 // a call the shim answers successfully. `[]` is also truthy, so the page's
2036 // `payload || {}` leaves it intact.
2037 const allocator = testing.allocator;
2038 const json = try shapeRowsForTesting(allocator, &.{});
2039 defer allocator.free(json);
2040
2041 try testing.expectEqualStrings("[]", json);
2042
2043 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2044 defer parsed.deinit();
2045 try testing.expectEqual(@as(usize, 0), parsed.value.array.items.len);
2046}
2047
2048test "several contacts are one comma-separated array, in enumeration order" {
2049 const allocator = testing.allocator;
2050 const json = try shapeRowsForTesting(allocator, &.{
2051 .{
2052 .id = "1",
2053 .given_name = "A",
2054 .family_name = "One",
2055 .display_name = "A One",
2056 .phones = &.{},
2057 .emails = &.{},
2058 },
2059 .{
2060 .id = "2",
2061 .given_name = "B",
2062 .family_name = "Two",
2063 .display_name = "B Two",
2064 .phones = &.{"555"},
2065 .emails = &.{},
2066 },
2067 .{
2068 .id = "3",
2069 .given_name = "C",
2070 .family_name = "Three",
2071 .display_name = "C Three",
2072 .phones = &.{},
2073 .emails = &.{"c@example.com"},
2074 },
2075 });
2076 defer allocator.free(json);
2077
2078 try testing.expect(json[0] == '[');
2079
2080 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2081 defer parsed.deinit();
2082
2083 const items = parsed.value.array.items;
2084 try testing.expectEqual(@as(usize, 3), items.len);
2085 try testing.expectEqualStrings("1", items[0].object.get("id").?.string);
2086 try testing.expectEqualStrings("2", items[1].object.get("id").?.string);
2087 try testing.expectEqualStrings("3", items[2].object.get("id").?.string);
2088 try testing.expectEqualStrings("555", items[1].object.get("phoneNumbers").?.array.items[0].string);
2089}
2090
2091test "user data that needs escaping stays valid JSON" {
2092 // A contact's name is user data. Before escaping, a single `"` in it would
2093 // close the string literal inside the `evaluateJavaScript:` source and turn
2094 // the whole reply into a syntax error in the page rather than a wrong field.
2095 const allocator = testing.allocator;
2096 const json = try shapeRowsForTesting(allocator, &.{.{
2097 .id = "x\\y",
2098 .given_name = "Ann \"Annie\"",
2099 .family_name = "O'Neil\nJr",
2100 .display_name = "Ann\t\"Annie\" O'Neil",
2101 .phones = &.{"+1\\555"},
2102 .emails = &.{"q\"@example.com"},
2103 }});
2104 defer allocator.free(json);
2105
2106 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2107 defer parsed.deinit();
2108
2109 const one = parsed.value.array.items[0].object;
2110 try testing.expectEqualStrings("x\\y", one.get("id").?.string);
2111 try testing.expectEqualStrings("Ann \"Annie\"", one.get("givenName").?.string);
2112 try testing.expectEqualStrings("O'Neil\nJr", one.get("familyName").?.string);
2113 try testing.expectEqualStrings("Ann\t\"Annie\" O'Neil", one.get("displayName").?.string);
2114 try testing.expectEqualStrings("+1\\555", one.get("phoneNumbers").?.array.items[0].string);
2115 try testing.expectEqualStrings("q\"@example.com", one.get("emailAddresses").?.array.items[0].string);
2116}
2117
2118test "the joined name puts exactly one space in and trims nothing itself" {
2119 // The trim is Foundation's — Zs plus U+0009, both ends, interior runs
2120 // preserved — so this half must not pre-empt it. A name with a double
2121 // space in it stays double-spaced in Swift too.
2122 const allocator = testing.allocator;
2123
2124 const both = try joinName(allocator, "Ada", "Lovelace");
2125 defer allocator.free(both);
2126 try testing.expectEqualStrings("Ada Lovelace", both);
2127
2128 // The cases the trim exists for: one half empty leaves a trailing or
2129 // leading space, and both empty leaves a lone space.
2130 const given_only = try joinName(allocator, "Solo", "");
2131 defer allocator.free(given_only);
2132 try testing.expectEqualStrings("Solo ", given_only);
2133
2134 const family_only = try joinName(allocator, "", "Lovelace");
2135 defer allocator.free(family_only);
2136 try testing.expectEqualStrings(" Lovelace", family_only);
2137
2138 const neither = try joinName(allocator, "", "");
2139 defer allocator.free(neither);
2140 try testing.expectEqualStrings(" ", neither);
2141
2142 // Interior whitespace is the caller's data, not padding.
2143 const interior = try joinName(allocator, "John Q", "Doe");
2144 defer allocator.free(interior);
2145 try testing.expectEqualStrings("John Q Doe", interior);
2146}
2147
2148// ---------------------------------------------------------------------------
2149// addContact's reply
2150// ---------------------------------------------------------------------------
2151
2152test "the addContact reply is a bare quoted string, not an object" {
2153 // `resolveCallback(callbackId, result: contact.identifier)` with
2154 // `.fragmentsAllowed` puts a quoted string on the wire, and the page does
2155 // `'Contact created with ID: ' + id`. An object here prints
2156 // `[object Object]`.
2157 const allocator = testing.allocator;
2158 const json = try shapeIdentifier(allocator, "AB12-CD34-EF56");
2159 defer allocator.free(json);
2160
2161 try testing.expectEqualStrings("\"AB12-CD34-EF56\"", json);
2162
2163 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2164 defer parsed.deinit();
2165 try testing.expectEqualStrings("AB12-CD34-EF56", parsed.value.string);
2166}
2167
2168test "an identifier that needs escaping stays a valid JSON string" {
2169 // A `CNContact` identifier is a UUID today. It is still the identifier
2170 // Contacts hands back rather than one this file mints, so it is escaped
2171 // like any other borrowed string.
2172 const allocator = testing.allocator;
2173 const json = try shapeIdentifier(allocator, "id\"with\\quotes");
2174 defer allocator.free(json);
2175
2176 const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
2177 defer parsed.deinit();
2178 try testing.expectEqualStrings("id\"with\\quotes", parsed.value.string);
2179}
2180
2181// ---------------------------------------------------------------------------
2182// Failure replies
2183// ---------------------------------------------------------------------------
2184
2185test "a denial is a rejection, and these are the exact bytes it puts on the wire" {
2186 // `denied` calls `ios_async.deliverErrorCode(..., PermissionDenied)`, which
2187 // lands in `deliverOnMain` -> `sendErrorToJS`. This builds the same envelope
2188 // that path builds, so the page-visible result of a declined prompt is
2189 // pinned rather than described.
2190 //
2191 // Two things at once. First `"error":true` — it goes out through
2192 // `__craftBridgeError`, which *rejects*; a `{"granted":false}` resolve
2193 // would run the page's then-branch for a read that never happened. Second,
2194 // the divergence in bytes: Swift sends `code:"CRAFT_ERROR"` with the
2195 // NSError's `localizedDescription` (or `"Permission denied"`), and this is
2196 // what the page gets instead.
2197 const allocator = testing.allocator;
2198 var ctx = bridge_error.ErrorContext.init(
2199 bridge_error.BridgeError.PermissionDenied,
2200 A.get_contacts,
2201 bridge_error.errorMessage(bridge_error.BridgeError.PermissionDenied),
2202 );
2203 ctx.request_id = 7;
2204
2205 const json = try ctx.toJSON(allocator);
2206 defer allocator.free(json);
2207
2208 try testing.expectEqualStrings(
2209 "{\"error\":true,\"code\":\"PERMISSION_DENIED\",\"action\":\"getContacts\"," ++
2210 "\"message\":\"Permission denied\",\"id\":7}",
2211 json,
2212 );
2213
2214 // And it is not a resolve shape: a rejection carries no contacts.
2215 try testing.expect(std.mem.indexOf(u8, json, "givenName") == null);
2216}
2217
2218test "a completion-side failure reports its cause rather than the nearest word" {
2219 // The rule the migration keeps paying for: an accurate code sends the
2220 // reader to the right place. Allocation failure is not a native call
2221 // failing, and a native call failing is not a permission problem.
2222 try testing.expectEqual(
2223 bridge_error.BridgeError.AllocationFailed,
2224 failureCode(error.OutOfMemory),
2225 );
2226 try testing.expectEqual(
2227 bridge_error.BridgeError.PermissionDenied,
2228 failureCode(error.PermissionDenied),
2229 );
2230 try testing.expectEqual(
2231 bridge_error.BridgeError.NativeCallFailed,
2232 failureCode(error.EmbeddedNulInNativeString),
2233 );
2234 try testing.expectEqual(
2235 bridge_error.BridgeError.NativeCallFailed,
2236 failureCode(error.NativeCallFailed),
2237 );
2238}
2239
2240// ---------------------------------------------------------------------------
2241// Slot bookkeeping
2242// ---------------------------------------------------------------------------
2243
2244test "each async slot carries its own call, so concurrent reads do not collide" {
2245 // The difference from the picker, which owns one on-screen resource and
2246 // refuses a second call. Two `getContacts` are two reads and both must be
2247 // answerable; the slot is leased exclusively by its ticket, so publishing
2248 // one can never displace another.
2249 resetSlotsForTesting();
2250 defer resetSlotsForTesting();
2251
2252 publishPendingCall(.{ .ticket = fakeTicket(3, 7), .store = null, .work = fakeGetWork() });
2253 publishPendingCall(.{ .ticket = fakeTicket(4, 9), .store = null, .work = fakeGetWork() });
2254
2255 const first = takePendingCall(3) orelse return error.PendingWentMissing;
2256 try testing.expectEqual(@as(u5, 3), first.ticket.index);
2257 try testing.expectEqual(@as(u32, 7), first.ticket.generation);
2258
2259 // Taking one leaves the other alone.
2260 const second = takePendingCall(4) orelse return error.PendingWentMissing;
2261 try testing.expectEqual(@as(u32, 9), second.ticket.generation);
2262}
2263
2264test "a slot is handed over once, so a double-firing completion cannot reply twice" {
2265 resetSlotsForTesting();
2266 defer resetSlotsForTesting();
2267
2268 publishPendingCall(.{ .ticket = fakeTicket(1, 2), .store = null, .work = fakeGetWork() });
2269
2270 const taken = takePendingCall(1) orelse return error.PendingWentMissing;
2271 try testing.expectEqual(@as(u5, 1), taken.ticket.index);
2272
2273 // A duplicate fire finds nothing. `ios_async` would drop the stale
2274 // generation anyway, but the entry has to be empty here so the second fire
2275 // never releases the +1 objects a second time either.
2276 try testing.expect(takePendingCall(1) == null);
2277
2278 // And the slot is reusable rather than wedged.
2279 publishPendingCall(.{ .ticket = fakeTicket(1, 4), .store = null, .work = fakeGetWork() });
2280 try testing.expect(takePendingCall(1) != null);
2281}
2282
2283test "a completion for a slot with no call takes nothing" {
2284 // The stray-completion path every invoke begins with. It has to be a no-op
2285 // rather than an answer, because the next call to occupy the slot would
2286 // otherwise receive this one's result.
2287 resetSlotsForTesting();
2288 defer resetSlotsForTesting();
2289
2290 try testing.expect(takePendingCall(0) == null);
2291 try testing.expect(takePendingCall(ios_async.max_in_flight - 1) == null);
2292}
2293
2294test "the collector is visible for one walk and gone after it" {
2295 // The enumeration block finds its writer through this table. Published
2296 // before `enumerateContacts…` and cleared the moment it returns, which is
2297 // what makes the pointer-to-a-stack-frame safe.
2298 resetSlotsForTesting();
2299 defer resetSlotsForTesting();
2300
2301 var writer = RowWriter{};
2302 defer writer.deinit(testing.allocator);
2303
2304 var collector = Collector{
2305 .allocator = testing.allocator,
2306 .sels = std.mem.zeroes(ReadSels),
2307 .whitespace = null,
2308 .writer = &writer,
2309 };
2310
2311 try testing.expect(collectorFor(5) == null);
2312 publishCollector(5, &collector);
2313 try testing.expectEqual(&collector, collectorFor(5).?);
2314 // Slots are independent: publishing one does not make another visible.
2315 try testing.expect(collectorFor(6) == null);
2316
2317 clearCollector(5);
2318 try testing.expect(collectorFor(5) == null);
2319}
2320
2321test "a contact that cannot be read stops the walk instead of shortening the book" {
2322 // `collectContacts` checks `collector.failure` *before* the BOOL, because a
2323 // block that set `stop` leaves the enumeration returning YES. Reporting the
2324 // rows read so far would tell the page the user has three contacts when
2325 // they have three hundred.
2326 //
2327 // The half of that mechanism a host can drive is the block, so it is driven
2328 // for real rather than described: `enumerationFired` with a nil `CNContact`,
2329 // which `appendContact` refuses before it sends a single message. Nothing
2330 // here touches Contacts.framework. (Asserting a `failure` this test had
2331 // itself assigned would have asserted only the assignment.)
2332 if (!is_darwin) return error.SkipZigTest;
2333
2334 resetSlotsForTesting();
2335 defer resetSlotsForTesting();
2336
2337 const allocator = testing.allocator;
2338
2339 var writer = RowWriter{};
2340 defer writer.deinit(allocator);
2341 try writer.begin(allocator);
2342 try writer.append(allocator, .{
2343 .id = "1",
2344 .given_name = "A",
2345 .family_name = "One",
2346 .display_name = "A One",
2347 .phones = &.{},
2348 .emails = &.{},
2349 });
2350
2351 var collector = Collector{
2352 .allocator = allocator,
2353 .sels = std.mem.zeroes(ReadSels),
2354 .whitespace = null,
2355 .writer = &writer,
2356 };
2357 publishCollector(2, &collector);
2358
2359 // The failure is recorded and the walk is told to stop. No row is appended:
2360 // half a contact in the array is the fabricated success this path exists to
2361 // prevent.
2362 var stop = false;
2363 enumerationFired(2, null, &stop);
2364 try testing.expect(stop);
2365 try testing.expect(collector.failure != null);
2366 try testing.expectEqual(@as(usize, 1), writer.rows);
2367
2368 // The partial array exists and is well-formed — which is exactly why the
2369 // failure flag, and not the buffer's contents, decides the reply.
2370 try testing.expectEqual(
2371 bridge_error.BridgeError.NativeCallFailed,
2372 failureCode(collector.failure.?),
2373 );
2374
2375 // A fire that lands before the enumeration notices `stop` keeps stopping,
2376 // and neither overwrites the first cause nor adds a row.
2377 const first_cause = collector.failure.?;
2378 stop = false;
2379 enumerationFired(2, null, &stop);
2380 try testing.expect(stop);
2381 try testing.expectEqual(first_cause, collector.failure.?);
2382 try testing.expectEqual(@as(usize, 1), writer.rows);
2383
2384 // And a fire for a slot with no live walk stops rather than reaching for a
2385 // writer that is not there.
2386 clearCollector(2);
2387 stop = false;
2388 enumerationFired(2, null, &stop);
2389 try testing.expect(stop);
2390}
2391
2392// ---------------------------------------------------------------------------
2393// Framework constants and blocks
2394// ---------------------------------------------------------------------------
2395
2396test "the fetch keys are the five Swift asks for, in its order" {
2397 // A key missing from this list does not fail: it produces a contact whose
2398 // property access raises CNContactPropertyNotFetchedException, which is an
2399 // uncatchable SIGABRT. The list is the guard, so the list is pinned.
2400 try testing.expectEqual(@as(usize, 5), contact_key_symbols.len);
2401 try testing.expectEqualStrings("CNContactGivenNameKey", contact_key_symbols[0]);
2402 try testing.expectEqualStrings("CNContactFamilyNameKey", contact_key_symbols[1]);
2403 try testing.expectEqualStrings("CNContactPhoneNumbersKey", contact_key_symbols[2]);
2404 try testing.expectEqualStrings("CNContactEmailAddressesKey", contact_key_symbols[3]);
2405 try testing.expectEqualStrings("CNContactIdentifierKey", contact_key_symbols[4]);
2406}
2407
2408test "the labels are resolved by symbol, never guessed" {
2409 // A wrong label does not fail — it files the number under the wrong heading
2410 // in somebody's address book. These are the symbol *names*; the values come
2411 // from Contacts.framework at runtime.
2412 try testing.expectEqualStrings("CNLabelPhoneNumberMain", label_phone_main);
2413 try testing.expectEqualStrings("CNLabelHome", label_email_home);
2414}
2415
2416test "CNEntityType.contacts is 0, as an NSInteger" {
2417 // Passed in an integer register by a `callconv(.c)` signature that has to
2418 // name its width: `CNEntityType` is `NSInteger`, so `c_long`.
2419 try testing.expectEqual(@as(c_long, 0), cn_entity_type_contacts);
2420 try testing.expectEqual(c_long, @TypeOf(cn_entity_type_contacts));
2421}
2422
2423test "every slot has its own block, so a completion cannot answer another call" {
2424 // The invoke is where a slot index is baked in. If `makeBlocks` handed
2425 // every slot the same invoke, sixteen callers would share one ticket and
2426 // fifteen promises would be answered with somebody else's data — silently,
2427 // because every one of those replies is well-formed.
2428 if (!is_darwin) return error.SkipZigTest;
2429
2430 for (access_blocks, 0..) |a, i| {
2431 for (access_blocks[i + 1 ..]) |b| {
2432 try testing.expect(a.invoke != b.invoke);
2433 }
2434 // The two block kinds are different functions with different
2435 // signatures; sharing one would read arguments from the wrong
2436 // registers.
2437 try testing.expect(a.invoke != enumeration_blocks[i].invoke);
2438 }
2439
2440 // A global block: never copied, so no heap lifetime and no copy/dispose
2441 // helpers. The size in the descriptor is the literal's, not a guess.
2442 try testing.expectEqual(@as(c_int, 1 << 28), BLOCK_IS_GLOBAL);
2443 try testing.expectEqual(BLOCK_IS_GLOBAL, access_blocks[0].flags);
2444 try testing.expectEqual(@as(c_ulong, @sizeOf(Block)), block_descriptor.size);
2445
2446 // And the accessor really hands out the block belonging to the ticket.
2447 try testing.expectEqual(
2448 @as(*anyopaque, @ptrCast(&access_blocks[3])),
2449 accessBlock(fakeTicket(3, 1)),
2450 );
2451 try testing.expectEqual(
2452 @as(*anyopaque, @ptrCast(&enumeration_blocks[3])),
2453 enumerationBlock(3),
2454 );
2455}
2456
2457test "nothing in this module emits an event" {
2458 // Neither action has a `sendToWeb` on any Swift path and `test-bridges.html`
2459 // registers no contacts listener, so an event from here would be a message
2460 // the shim never sends — and `ios_events.Event` has no name for one. This
2461 // is a source scan rather than prose because the import is the whole
2462 // mistake: adding it is what makes the rest possible.
2463 const source = @embedFile("bridge_mobile_contacts.zig");
2464 try testing.expect(std.mem.indexOf(u8, source, "") == null);
2465
2466 // Non-vacuity: the same scan finds the two reply calls that *are* made, so
2467 // a needle that stopped matching anything could not pass unnoticed.
2468 //
2469 // Both needles carry an argument and are assembled from halves, and both
2470 // details are load-bearing. Spelled out whole, each needle would match the
2471 // very line asserting it; without the argument, each would match the prose
2472 // further up this file, which names both functions. The sibling modules get
2473 // the same property from an escaped quote inside their needle — these two
2474 // calls have nowhere to put one.
2475 const resolve_call = "ios_async.deliverJson(call" ++ ".ticket,";
2476 const reject_call = "ios_async.deliverErrorCode(call" ++ ".ticket,";
2477 try testing.expect(std.mem.indexOf(u8, source, resolve_call) != null);
2478 try testing.expect(std.mem.indexOf(u8, source, reject_call) != null);
2479}