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/build.zigmodified+9-0
Changes to packages/zig/build.zig
@@ -467,6 +467,15 @@ pub fn build(b: *std.Build) void {
467467 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_contactpicker.zig", .{
468468 .root_source_file = b.path("src/bridge_mobile_contactpicker.zig"),
469469 });
470 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_calendar.zig", .{
471 .root_source_file = b.path("src/bridge_mobile_calendar.zig"),
472 });
473 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_contacts.zig", .{
474 .root_source_file = b.path("src/bridge_mobile_contacts.zig"),
475 });
476 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_speech.zig", .{
477 .root_source_file = b.path("src/bridge_mobile_speech.zig"),
478 });
470479
471480 const menubar_tests = b.addTest(.{
472481 .root_module = b.createModule(.{
packages/zig/src/bridge_mobile_calendar.zigadded+2392-0
Changes to packages/zig/src/bridge_mobile_calendar.zig
@@ -0,0 +1,2392 @@
1//! `getCalendarEvents`, `createCalendarEvent`, `deleteCalendarEvent` — EventKit,
2//! ported from `CraftApp.swift:716-731` (dispatch) and `3289-3361` (the three
3//! implementations). Every claim below is measured against those lines.
4//!
5//! Three actions, three different reply shapes, and one of them is not even
6//! asynchronous. Nothing here emits: there is no `sendToWeb(` anywhere in
7//! `CraftApp.swift:3289-3361`, no calendar member in `ios_events.Event`, and no
8//! `craftCalendar*` event name on the iOS side at all — the one
9//! `_craftCalendarResolve` the repo does contain is the *Android* template's
10//! promise holder (`packages/android/templates/CraftBridge.kt.template:397`),
11//! not something `dispatchEvent` ever carries. So `ios_events` is deliberately
12//! not imported. There is no runtime delegate either — EventKit answers through a
13//! completion block, not a protocol — so `ios_delegate` is not imported.
14//!
15//! ## The payloads, field by field, from the injected JS
16//!
17//! The only page surface is `window.craft.calendar.*` (`CraftApp.swift:1783-1808`).
18//! There are no flat aliases; `packages/ios/README.md:329-341`,
19//! `packages/typescript/types/craft.d.ts:282-295` and both
20//! `templates/test-bridges.html:831,842` document and call a
21//! `window.craft.getCalendarEvents(...)` that has never existed. Those harness
22//! tests throw `TypeError` inside their own `try` and never reach the bridge, so
23//! the harness gives **zero** evidence about anything here. The contract below
24//! comes from `resolveCallback`/`rejectCallback` and `.fragmentsAllowed`, which
25//! is what actually runs.
26//!
27//! - `getCalendarEvents` posts `{startDate, endDate}` **top-level**, read as
28//! `body["startDate"] as? Double`. They are **milliseconds**: Swift divides
29//! by 1000 to make an `NSTimeInterval` (3297-3298).
30//! - `createCalendarEvent` posts `{event: {...}}` — a **nested object**, whose
31//! keys are `title`, `location`, `notes`, `startDate`, `endDate`, `isAllDay`.
32//! - `deleteCalendarEvent` posts `{eventId}` top-level, a string.
33//!
34//! `callbackId` is the shim's own correlation and never reaches a Zig module;
35//! `ios_async` owns the request id here.
36//!
37//! **None of these promises has a timeout.** All three are hand-built
38//! `new Promise` (1788, 1796, 1804) rather than going through `_createCallback`
39//! (1196-1210, the only thing carrying the 30-second `setTimeout`). An
40//! unanswered call parks the page forever, which is why every path in this file
41//! ends in a reply or an error.
42//!
43//! ## One rule for wrong-typed fields, applied everywhere
44//!
45//! Swift reads every field through `as? T`, which yields nil for the wrong type
46//! and then falls back to a default — `?? ""`, `?? false`, `Date()`, or "leave
47//! the property unset". That means `craft.calendar.getEvents('2024-01-01')`
48//! silently returns events from **now to now + 1 month**, and the page cannot
49//! tell. So:
50//!
51//! - **Absent, or JSON `null`** → Swift's documented default, reproduced
52//! exactly. That is the real contract and it is honoured.
53//! - **Present and of a type `as? T` accepts** → accepted identically,
54//! including the `NSNumber`-as-`Bool` rule where only `0` and `1` are
55//! booleans (the same rule `bridge_mobile_contactpicker.zig:448-460` spells
56//! out for `multiple`).
57//! - **Present and of a type `as? T` rejects** → `INVALID_PARAMETER`, naming
58//! the field, rather than the shim's silent default. This is a divergence
59//! and it is deliberate: a range the page did not ask for, answered as
60//! success, is the wrong-answer-reported-as-success class this migration
61//! exists to remove. `bridge_mobile_motion.zig:760-775` made the same call
62//! for `interval`.
63//!
64//! One case is not a preference but a limit: `true as? Double` on a
65//! `__NSCFBoolean` is a genuine wart whose answer differs between Foundation
66//! versions and is not settled here. `{"startDate":true}` is refused rather than
67//! guessed at in either direction; it is nonsense on any reading.
68//!
69//! A string containing an embedded NUL is also refused. `stringWithUTF8String:`
70//! stops at the NUL, so carrying it across would silently create an event with a
71//! truncated title, where Swift's `as? String` preserves the whole thing.
72//!
73//! The same refusal applies in the *outbound* direction, and it is coarser
74//! there: `readString` compares `UTF8String`'s NUL-terminated bytes against
75//! `lengthOfBytesUsingEncoding:`, so an existing calendar event whose title
76//! contains U+0000 fails the whole `getCalendarEvents` call rather than one row.
77//! Swift's `JSONSerialization` would have carried it. Reporting a prefix as the
78//! whole title is the one thing worse than failing, and a partial list is the
79//! other; this is the same call `bridge_mobile_notifications.zig` and
80//! `bridge_mobile_contactpicker.zig` already made.
81//!
82//! ## The three reply shapes, from `resolveCallback` + `.fragmentsAllowed`
83//!
84//! - **`getCalendarEvents` resolves a bare JSON array of objects** (3303-3315),
85//! seven keys each, **always all seven present**: `id`, `title`, `location`,
86//! `notes`, `startDate`, `endDate`, `isAllDay`. `location` and `notes` are
87//! `""` when the event has none — never omitted and never `null`, whatever
88//! `craft.d.ts:1174-1183` says with its `location?: string`. The runtime is
89//! the contract. `startDate`/`endDate` are **fractional milliseconds**.
90//! Swift's `[String: Any]` has no key order at all, so one is fixed here to
91//! make the bytes testable — the same call `shapeFix` makes in
92//! `bridge_mobile_location.zig:611`. An empty result is `[]` and **resolves**.
93//! - **`createCalendarEvent` resolves a bare JSON string** — the new
94//! `eventIdentifier` (3342) — **or bare `null`**. `EKEvent.eventIdentifier`
95//! is `String!`, and a nil implicitly-unwrapped optional coerced to `Any`
96//! becomes `Optional<String>.none`, which `.fragmentsAllowed` renders as
97//! `null`. Same path `bridge_mobile_securestore.zig:23-24` documents for
98//! `secureGet`.
99//! - **`deleteCalendarEvent` resolves bare `true`** (3357), not `undefined`.
100//! `craft.d.ts:295` declares `Promise<void>`; the wire value is `true`.
101//!
102//! ### The `payload || {}` hazard, which bites exactly one value
103//!
104//! Zig replies through `bridge_error.sendResultToJS` →
105//! `window.__craftBridgeResult`, and `js/craft-bridge.js:80` resolves
106//! `payload || {}`. Through **this** route a falsy fragment is coerced:
107//! `null → {}`. Swift's own `window.craft._resolveCallback` route does not do
108//! that. So a `createCalendarEvent` whose save succeeded but whose
109//! `eventIdentifier` came back nil hands the page `{}` rather than `null`.
110//!
111//! `null` is still sent, deliberately. The alternatives are worse: rejecting
112//! would tell the page the event was **not** created when it **was** — the
113//! mirror image of fabricated success — and substituting `""` is swallowed by
114//! `|| {}` just the same while also being a lie about the identifier. A
115//! synthetic id would be a lie the page could act on. This is written down
116//! rather than papered over.
117//!
118//! ## What each action actually does, and where it diverges
119//!
120//! **Both read paths request access with the deprecated
121//! `requestAccessToEntityType:completion:`**, matching Swift 3291/3320, which has
122//! **no `if #available` fork** — unlike `requestPermission`
123//! (`CraftApp.swift:2852-2867`, which does fork to
124//! `requestFullAccessToEvents`). Matching is not laziness: `packages/ios/src/index.ts:190`
125//! writes **only** `NSCalendarsUsageDescription`, and
126//! `requestFullAccessToEventsWithCompletion:` requires
127//! `NSCalendarsFullAccessUsageDescription`, which nothing in this repo ever
128//! writes. Calling the iOS 17 API would terminate the app. (Swift's own
129//! `requestPermission("calendar")` arm has that latent crash; it is not
130//! replicated here.)
131//!
132//! **The Info.plist key is a hard precondition for the two async actions, not
133//! merely evidence of a config flag.** `requestAccessToEntityType:` in a process
134//! whose Info.plist lacks the usage description raises
135//! `NSInternalInconsistencyException`, which from Zig is an uncatchable SIGABRT.
136//! So the gate runs **before** the store is ever touched. This is where the
137//! wording deliberately differs from `bridge_mobile_contactpicker.zig`, whose
138//! `CNContactPickerViewController` genuinely needs no authorization. For
139//! `deleteCalendarEvent` — which requests nothing (3350) — the key is only
140//! evidence of `config.enableCalendar`, and it is checked for that reason.
141//!
142//! **`config.enableCalendar` has no Zig mirror**, the same gap
143//! `bridge_mobile_contactpicker.zig` records for `enableContacts`. The plist key
144//! `packages/ios/src/index.ts:190` writes iff that flag is the stand-in, and the
145//! mapping is exact — nothing else writes or shares it.
146//!
147//! **Behaviour changes on every disabled or malformed path, in Zig's favour.**
148//! Swift's three cases have no `else` (717-731): an app with
149//! `enableCalendar: false`, a `createCalendarEvent` with no `event` object, and a
150//! `deleteCalendarEvent` with no `eventId` string all answer the page with
151//! **nothing**, on a promise with no timeout — a hang for the life of the page.
152//! Here they are `PERMISSION_DENIED`, `MISSING_DATA` and `MISSING_DATA`.
153//! Everybody settles, which is strictly better, and it is observable.
154//!
155//! **A permission denial rejects `PERMISSION_DENIED`, not `NATIVE_CALL_FAILED`.**
156//! `ios_async.deliverErrorCode` exists for exactly this. The message is
157//! byte-identical to `createCalendarEvent`'s (3322) and to `getCalendarEvents`'
158//! fallback (3293); the code changes from Swift's default `CRAFT_ERROR`, which is
159//! the accurate cause rather than the historical one.
160//! `getCalendarEvents`' other variant — `error?.localizedDescription`, a
161//! localised EventKit string — is **not** reproducible through `BridgeError` and
162//! is not claimed: it goes to the log instead.
163//!
164//! **`deleteCalendarEvent` requests no permission, and that is reproduced
165//! faithfully even though it is misleading.** `eventWithIdentifier:` returns nil
166//! both for an absent event and for an unauthorized store, so on a fresh install
167//! every delete says "Event not found" (`NOT_FOUND` here). Adding a permission
168//! request would show the user a prompt at a moment the shim never shows one.
169//!
170//! **A save that fails is refused with the reason logged, never pre-empted.**
171//! `defaultCalendarForNewEvents` can be nil on a freshly-erased simulator or a
172//! device with no writable calendar; Swift assigns it unconditionally (3338) and
173//! lets `saveEvent:span:error:` fail with "No calendar has been set". The same
174//! happens for a missing start or end date, which is why absent nested dates are
175//! **not** given a default here. In every case the `NSError` goes to the log and
176//! the page gets `NATIVE_CALL_FAILED`; there is no fallback to "the first
177//! writable calendar", which would write the event somewhere the shim would not.
178//!
179//! **A fetched event with a nil `startDate` or `endDate` rejects rather than
180//! crashing.** Swift's `event.startDate.timeIntervalSince1970` force-unwraps an
181//! implicitly-unwrapped optional. Zig cannot fabricate a `0` there — that would
182//! be an event dated 1970 reported as real — so the whole reply is refused with
183//! the event's id in the log.
184//!
185//! `CraftApp.swift:3301` also force-unwraps `predicate!` inside a `[weak self]`
186//! closure, which crashes if the coordinator died first. Zig has no `self` and
187//! no such hazard; the nil is checked instead of translated.
188//!
189//! ## Concurrency: a side table, not a single slot
190//!
191//! The completion block is global (`BLOCK_IS_GLOBAL`, so `Block_copy` is the
192//! identity and escaping it to EventKit costs no heap lifetime), which means it
193//! captures nothing and knows only the slot index baked into its comptime
194//! invoke. Two things therefore live in a per-slot side table: the ticket, and
195//! the request parameters the completion still needs — the millisecond range for
196//! a fetch, or the parsed event for a save. That is
197//! `bridge_mobile_notifications.zig:481-533`'s pattern exactly.
198//!
199//! A *single* pending slot, as `bridge_mobile_contactpicker.zig` uses, would be
200//! wrong here: there is no on-screen picker making concurrency impossible, and
201//! two `getCalendarEvents` in flight is an ordinary page. The table is one entry
202//! per `ios_async` slot, mutex-guarded, and a full pool is an explicit refusal
203//! rather than silence.
204//!
205//! The strings a pending `createCalendarEvent` owns are allocated from
206//! `std.heap.c_allocator` at dispatch and freed by the completion, because the
207//! dispatcher's allocator is not guaranteed to outlive the frame and the
208//! completion runs long after it is gone.
209//!
210//! No reply is ever sent from the completion directly. `ios_async` is the only
211//! channel, and its main-queue hop is not merely thread safety: it restores the
212//! `request_context` captured at dispatch, so the reply names the call that is
213//! waiting instead of falling back to action-name matching.
214
215const std = ;
216const builtin = ;
217const capabilities = ;
218const bridge_error = ;
219const objc_runtime = ;
220const ios_async = ;
221const compat_mutex = ;
222const memory = ;
223
224const objc = objc_runtime.objc;
225const is_darwin = builtin.target.os.tag.isDarwin();
226
227/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
228/// location/notifications/contactpicker precedent: `objc_runtime.objc` is an
229/// empty struct off Darwin and a function *signature* is analysed even when a
230/// comptime platform guard prunes the body, so naming `objc.id` in the
231/// `callconv(.c)` types below would break the host build. A single optional
232/// pointer, never `?objc.id` — a double optional is illegal in `callconv(.c)`.
233const Id = ?*anyopaque;
234
235/// The action names, spelled exactly as the Swift `case` labels spell them.
236/// `test/ios_conformance_test.zig` matches this block against those labels in
237/// both directions, and fails the build if two modules declare one name.
238pub const A = struct {
239 pub const get_calendar_events = "getCalendarEvents";
240 pub const create_calendar_event = "createCalendarEvent";
241 pub const delete_calendar_event = "deleteCalendarEvent";
242};
243
244/// All three are `.result`: every Swift path out of every one of them ends in
245/// exactly one `resolveCallback` or one `rejectCallback`. `.none` would be a
246/// claim that nothing answers, and on these particular promises — hand-built, no
247/// `setTimeout` — that is a page parked forever rather than for thirty seconds.
248///
249/// All three are `.live`, not `.unavailable`: in an app built with
250/// `enableCalendar` all three work. A refusal here is always a specific
251/// condition (calendar not configured, EventKit not linked, permission denied,
252/// the async pool full), never the normal answer. `.unavailable` would make Zig
253/// dispatch and then refuse an action the shim serves.
254pub const capability_actions = [_]capabilities.ActionDecl{
255 .{ .name = A.get_calendar_events, .reply = .result },
256 .{ .name = A.create_calendar_event, .reply = .result },
257 .{ .name = A.delete_calendar_event, .reply = .result },
258};
259
260/// Which handler an action selects, split out from `handleMessage` so the
261/// table-versus-dispatch agreement is assertable on a host without touching
262/// EventKit.
263const Route = enum { get_events, create_event, delete_event };
264
265fn routeFor(action: []const u8) ?Route {
266 if (std.mem.eql(u8, action, A.get_calendar_events)) return .get_events;
267 if (std.mem.eql(u8, action, A.create_calendar_event)) return .create_event;
268 if (std.mem.eql(u8, action, A.delete_calendar_event)) return .delete_event;
269 return null;
270}
271
272pub const CalendarBridge = struct {
273 allocator: std.mem.Allocator,
274
275 const Self = @This();
276
277 pub fn init(allocator: std.mem.Allocator) Self {
278 return .{ .allocator = allocator };
279 }
280
281 pub fn deinit(_: *Self) void {}
282
283 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
284 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
285 // Exhaustive, so a `Route` without a handler is a compile error.
286 return switch (route) {
287 .get_events => self.getCalendarEvents(data),
288 .create_event => self.createCalendarEvent(data),
289 .delete_event => self.deleteCalendarEvent(data),
290 };
291 }
292
293 /// `eventStore.requestAccess(to: .event) { … predicateForEvents … }`.
294 ///
295 /// Ordering is load-bearing. The payload is parsed **first**, so a malformed
296 /// body is reported the same on any platform and before any framework is
297 /// consulted. Then the Info.plist precondition, then every class and
298 /// selector the completion will need — all of it **before**
299 /// `ios_async.acquire`, because a completion has no way to report a failed
300 /// `sel_registerName` as anything but a log line. After the lease there are
301 /// exactly two statements, both infallible: publish the side-table entry
302 /// (which the completion reads), then make the framework call. A lease that
303 /// escaped without either would narrow the pool for the life of the process.
304 ///
305 /// The two `NSDate`s are built **inside** the completion rather than here,
306 /// which is where Swift builds them (3297-3298). It matters: the user may
307 /// spend seconds on the permission sheet, and "now" means now-when-granted
308 /// in the shim.
309 fn getCalendarEvents(self: *Self, data: []const u8) !void {
310 const range = try parseRange(self.allocator, data);
311
312 if (!is_darwin) return error.UnsupportedPlatform;
313
314 try requireCalendarConfigured(A.get_calendar_events);
315
316 const sels = try Sels.resolve(A.get_calendar_events);
317 const store = try ensureStore(A.get_calendar_events);
318
319 const ticket = ios_async.acquire(A.get_calendar_events) orelse
320 return poolFull(A.get_calendar_events);
321
322 // Published *before* the framework call, never after:
323 // `requestAccessToEntityType:` may invoke its completion synchronously
324 // when access is already granted, and a completion that found an empty
325 // entry would have no ticket to reply with.
326 publishPendingCall(ticket, sels, store, .{ .fetch = range });
327
328 requestAccess(store, sels, ticket);
329 }
330
331 /// `eventStore.requestAccess(to: .event) { … EKEvent … save … }`.
332 ///
333 /// Same ordering discipline as the fetch, plus one ownership rule: the
334 /// parsed event's strings are allocated from `std.heap.c_allocator` — the
335 /// allocator the completion frees them with — and the `errdefer` releases
336 /// them on every path that leaves before the side table takes ownership.
337 ///
338 /// That is also why `self.allocator` goes unused here, unlike in the other
339 /// two handlers: the dispatcher's allocator is not promised to outlive this
340 /// frame, and the completion runs long after it is gone.
341 fn createCalendarEvent(_: *Self, data: []const u8) !void {
342 // Not `self.allocator`: this outlives the dispatch frame, and
343 // `ios_async` delivers on `std.heap.c_allocator` too.
344 const owner = std.heap.c_allocator;
345 const new_event = try parseNewEvent(owner, data);
346 errdefer new_event.deinit(owner);
347
348 if (!is_darwin) return error.UnsupportedPlatform;
349
350 try requireCalendarConfigured(A.create_calendar_event);
351
352 const sels = try Sels.resolve(A.create_calendar_event);
353 const store = try ensureStore(A.create_calendar_event);
354
355 const ticket = ios_async.acquire(A.create_calendar_event) orelse
356 return poolFull(A.create_calendar_event);
357
358 // Ownership of `new_event`'s strings moves into the side table here.
359 publishPendingCall(ticket, sels, store, .{ .create = new_event });
360
361 requestAccess(store, sels, ticket);
362 }
363
364 /// `eventStore.event(withIdentifier:)` then `remove(_:span:)`.
365 ///
366 /// Fully synchronous — Swift requests no access here (3350) — so there is no
367 /// ticket, no block and no side table. The reply goes out on this frame,
368 /// where `request_context` still names the call, exactly as
369 /// `bridge_mobile_clipboard.zig` replies.
370 fn deleteCalendarEvent(self: *Self, data: []const u8) !void {
371 const event_id = try parseEventId(self.allocator, data);
372 defer self.allocator.free(event_id);
373
374 if (!is_darwin) return error.UnsupportedPlatform;
375
376 try requireCalendarConfigured(A.delete_calendar_event);
377
378 const sels = try Sels.resolve(A.delete_calendar_event);
379 const store = try ensureStore(A.delete_calendar_event);
380
381 const ns_id = try makeString(sels, self.allocator, event_id);
382
383 const FindFn = *const fn (Id, Id, Id) callconv(.c) Id;
384 const find: FindFn = @ptrCast(&objc.objc_msgSend);
385 const event = find(store, sels.event_with_identifier, ns_id) orelse {
386 // Also what an *unauthorized* store answers, because this path never
387 // requests access. Misleading, and the shim's exact behaviour; see
388 // the module comment for why a prompt is not added here.
389 std.log.info(
390 "deleteCalendarEvent: no event with that identifier (an unauthorized " ++
391 "store answers the same way, since this action requests no access)",
392 .{},
393 );
394 return bridge_error.BridgeError.NotFound;
395 };
396
397 var ns_error: Id = null;
398 const RemoveFn = *const fn (Id, Id, Id, c_long, ?*Id) callconv(.c) bool;
399 const remove: RemoveFn = @ptrCast(&objc.objc_msgSend);
400 if (!remove(store, sels.remove_event, event, ek_span_this_event, &ns_error)) {
401 logNativeError("deleteCalendarEvent: removeEvent:span:error: failed", ns_error, sels);
402 return bridge_error.BridgeError.NativeCallFailed;
403 }
404
405 bridge_error.sendResultToJS(self.allocator, A.delete_calendar_event, true_fragment);
406 }
407};
408
409/// `-[EKEventStore requestAccessToEntityType:completion:]` with this ticket's
410/// pre-built global block. Infallible by construction: the store, the selector
411/// and the block all exist by the time this is reached.
412fn requestAccess(store: Id, sels: Sels, ticket: ios_async.Ticket) void {
413 if (!is_darwin) return;
414
415 const RequestFn = *const fn (Id, Id, c_ulong, ?*anyopaque) callconv(.c) void;
416 const request: RequestFn = @ptrCast(&objc.objc_msgSend);
417 request(store, sels.request_access, ek_entity_type_event, accessBlock(ticket));
418}
419
420/// The answer for a full block pool, copied from `bridge_mobile_location`:
421/// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes'
422/// designated stand-in, and the point is that the caller gets an explicit
423/// rejection instead of a promise that never settles.
424fn poolFull(action: []const u8) bridge_error.BridgeError {
425 std.log.warn(
426 "{s} refused: all {d} async slots are in flight",
427 .{ action, ios_async.max_in_flight },
428 );
429 return bridge_error.BridgeError.InvalidParameter;
430}
431
432// =============================================================================
433// Payload parsing. Pure — no Objective-C — so the exact coercions Swift performs
434// are pinned by host tests on every platform.
435// =============================================================================
436
437/// `getCalendarEvents`' two top-level fields, still in **milliseconds**.
438///
439/// Kept as milliseconds rather than converted here because the division by 1000
440/// is Swift's (3297-3298) and belongs next to the `NSDate` it feeds; a struct
441/// holding "seconds" that was filled from a millisecond wire value is exactly
442/// the kind of unit slip nothing catches.
443const Range = struct {
444 start_ms: ?f64 = null,
445 end_ms: ?f64 = null,
446};
447
448/// The nested `event` object, with every string owned by the caller's allocator.
449///
450/// The optionals are three-state on the wire and two-state here: absent and
451/// JSON `null` both become `null`, which is what Swift's `as? String` produces
452/// and what sets the `EKEvent` property to nil. A *wrongly typed* value never
453/// reaches this struct — `parseNewEvent` refuses it.
454const NewEvent = struct {
455 /// `data["title"] as? String ?? ""` — never null.
456 title: []const u8,
457 location: ?[]const u8,
458 notes: ?[]const u8,
459 /// Absent stays absent. Swift's `if let` leaves `EKEvent.startDate` nil and
460 /// `saveEvent:span:error:` then fails with "No start date has been set";
461 /// inventing a default here would create an event the shim refuses to.
462 start_ms: ?f64,
463 end_ms: ?f64,
464 all_day: bool,
465
466 fn deinit(self: NewEvent, allocator: std.mem.Allocator) void {
467 allocator.free(self.title);
468 if (self.location) |s| allocator.free(s);
469 if (self.notes) |s| allocator.free(s);
470 }
471};
472
473/// A malformed or non-object `d` is `InvalidJSON` rather than a silent default.
474/// It is a Zig-only failure mode — `WKScriptMessage.body` is always a dictionary,
475/// so Swift never meets it — but guessing for a body that could not be read
476/// would be acting on values the page did not send.
477fn parseRange(allocator: std.mem.Allocator, data: []const u8) !Range {
478 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
479 return bridge_error.BridgeError.InvalidJSON;
480 defer parsed.deinit();
481
482 const root = switch (parsed.value) {
483 .object => |obj| obj,
484 else => return bridge_error.BridgeError.InvalidJSON,
485 };
486
487 return .{
488 .start_ms = try millisecondsFrom(root.get("startDate"), "startDate"),
489 .end_ms = try millisecondsFrom(root.get("endDate"), "endDate"),
490 };
491}
492
493fn parseNewEvent(allocator: std.mem.Allocator, data: []const u8) !NewEvent {
494 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
495 return bridge_error.BridgeError.InvalidJSON;
496 defer parsed.deinit();
497
498 const root = switch (parsed.value) {
499 .object => |obj| obj,
500 else => return bridge_error.BridgeError.InvalidJSON,
501 };
502
503 // `body["event"] as? [String: Any]` failing makes Swift's whole `case` body
504 // not run, which answers the page with nothing on an untimed promise. Two
505 // distinct causes, reported distinctly rather than collapsed.
506 const event_value = root.get("event") orelse {
507 std.log.warn("createCalendarEvent refused: the message carried no 'event' object", .{});
508 return bridge_error.BridgeError.MissingData;
509 };
510 const event = switch (event_value) {
511 .object => |obj| obj,
512 .null => {
513 std.log.warn("createCalendarEvent refused: 'event' was null", .{});
514 return bridge_error.BridgeError.MissingData;
515 },
516 else => {
517 std.log.warn("createCalendarEvent refused: 'event' is not an object", .{});
518 return bridge_error.BridgeError.InvalidParameter;
519 },
520 };
521
522 // Built field by field with an errdefer per owned string, so a refusal in a
523 // later field does not leak an earlier one.
524 const title = (try ownedStringFrom(allocator, event.get("title"), "event.title")) orelse
525 try allocator.dupe(u8, "");
526 errdefer allocator.free(title);
527
528 const location = try ownedStringFrom(allocator, event.get("location"), "event.location");
529 errdefer if (location) |s| allocator.free(s);
530
531 const notes = try ownedStringFrom(allocator, event.get("notes"), "event.notes");
532 errdefer if (notes) |s| allocator.free(s);
533
534 return .{
535 .title = title,
536 .location = location,
537 .notes = notes,
538 .start_ms = try millisecondsFrom(event.get("startDate"), "event.startDate"),
539 .end_ms = try millisecondsFrom(event.get("endDate"), "event.endDate"),
540 .all_day = try allDayFrom(event.get("isAllDay")),
541 };
542}
543
544/// `body["eventId"] as? String`, owned by the caller.
545///
546/// An empty string is *accepted*: Swift passes it to `event(withIdentifier: "")`,
547/// which answers nil, which is "Event not found". Refusing it here would answer a
548/// different question than the shim answers.
549fn parseEventId(allocator: std.mem.Allocator, data: []const u8) ![]u8 {
550 const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
551 return bridge_error.BridgeError.InvalidJSON;
552 defer parsed.deinit();
553
554 const root = switch (parsed.value) {
555 .object => |obj| obj,
556 else => return bridge_error.BridgeError.InvalidJSON,
557 };
558
559 const value = root.get("eventId") orelse {
560 std.log.warn("deleteCalendarEvent refused: the message carried no 'eventId'", .{});
561 return bridge_error.BridgeError.MissingData;
562 };
563
564 return switch (value) {
565 .null => {
566 std.log.warn("deleteCalendarEvent refused: 'eventId' was null", .{});
567 return bridge_error.BridgeError.MissingData;
568 },
569 .string => |s| blk: {
570 if (std.mem.indexOfScalar(u8, s, 0) != null) {
571 std.log.warn(
572 "deleteCalendarEvent refused: 'eventId' contains an embedded NUL, which " ++
573 "stringWithUTF8String: would silently truncate",
574 .{},
575 );
576 return bridge_error.BridgeError.InvalidParameter;
577 }
578 break :blk try allocator.dupe(u8, s);
579 },
580 else => {
581 std.log.warn("deleteCalendarEvent refused: 'eventId' is not a string", .{});
582 return bridge_error.BridgeError.InvalidParameter;
583 },
584 };
585}
586
587/// `as? Double`, as a partial function: null for absent, an error for a value
588/// `as? Double` would reject.
589///
590/// `std.json` parks a literal that overflows `i64`, or one written in a form it
591/// keeps verbatim, in `.number_string` with scanner-validated source bytes. It is
592/// still a number the page sent, so it is read rather than refused for its
593/// spelling — the same call `bridge_mobile_motion.zig:785` makes.
594///
595/// Non-finite is refused: `1e400` parses to `inf`, and an infinite
596/// `NSTimeInterval` is not a date. `.bool` is refused too — see the module
597/// comment on the `__NSCFBoolean` wart.
598fn millisecondsFrom(value: ?std.json.Value, comptime field: []const u8) !?f64 {
599 const v = value orelse return null;
600
601 const ms: f64 = switch (v) {
602 .null => return null,
603 .integer => |i| @floatFromInt(i),
604 .float => |f| f,
605 .number_string => |s| std.fmt.parseFloat(f64, s) catch {
606 std.log.warn(
607 "calendar refused: '" ++ field ++ "' is a number craft cannot read",
608 .{},
609 );
610 return bridge_error.BridgeError.InvalidParameter;
611 },
612 else => {
613 std.log.warn(
614 "calendar refused: '" ++ field ++ "' is not a number; Swift would silently " ++
615 "use its default instead, which the page cannot see",
616 .{},
617 );
618 return bridge_error.BridgeError.InvalidParameter;
619 },
620 };
621
622 if (!std.math.isFinite(ms)) {
623 std.log.warn("calendar refused: '" ++ field ++ "' is not a finite number", .{});
624 return bridge_error.BridgeError.InvalidParameter;
625 }
626 return ms;
627}
628
629/// `as? String`, copied into caller-owned memory. Null for absent or JSON null.
630fn ownedStringFrom(
631 allocator: std.mem.Allocator,
632 value: ?std.json.Value,
633 comptime field: []const u8,
634) !?[]const u8 {
635 const v = value orelse return null;
636
637 return switch (v) {
638 .null => null,
639 .string => |s| blk: {
640 if (std.mem.indexOfScalar(u8, s, 0) != null) {
641 std.log.warn(
642 "createCalendarEvent refused: '" ++ field ++ "' contains an embedded NUL, " ++
643 "which stringWithUTF8String: would silently truncate",
644 .{},
645 );
646 return bridge_error.BridgeError.InvalidParameter;
647 }
648 break :blk try allocator.dupe(u8, s);
649 },
650 else => {
651 std.log.warn(
652 "createCalendarEvent refused: '" ++ field ++ "' is not a string; Swift would " ++
653 "silently use its default instead",
654 .{},
655 );
656 return bridge_error.BridgeError.InvalidParameter;
657 },
658 };
659}
660
661/// `data["isAllDay"] as? Bool ?? false`.
662///
663/// The accepted set is exactly what a bridged `NSNumber as? Bool` accepts — a
664/// real boolean, or the numbers `0` and `1`, the rule
665/// `bridge_mobile_contactpicker.zig:448-460` spells out. `2` is truthy in
666/// JavaScript and is *not* a Bool to Foundation; Swift turns it into `false`,
667/// and here it is refused rather than silently creating a timed event for a page
668/// that asked for an all-day one.
669fn allDayFrom(value: ?std.json.Value) !bool {
670 const v = value orelse return false;
671
672 return switch (v) {
673 .null => false,
674 .bool => |b| b,
675 .integer => |i| switch (i) {
676 0 => false,
677 1 => true,
678 else => return allDayRefused(),
679 },
680 .float => |f| if (f == 0.0) false else if (f == 1.0) true else return allDayRefused(),
681 else => return allDayRefused(),
682 };
683}
684
685/// Always an error. Split out so the four refusing arms above say the same
686/// thing once, and so each arm stays an expression.
687fn allDayRefused() bridge_error.BridgeError {
688 std.log.warn(
689 "createCalendarEvent refused: 'event.isAllDay' is not a boolean Foundation would " ++
690 "accept (only true/false and the numbers 0 and 1 are); Swift would silently use " ++
691 "false instead",
692 .{},
693 );
694 return bridge_error.BridgeError.InvalidParameter;
695}
696
697// =============================================================================
698// Reply shaping. Pure — no Objective-C — so the exact bytes the page receives
699// are pinned by host tests.
700// =============================================================================
701
702/// `resolveCallback(callbackId, result: true)` under `.fragmentsAllowed`.
703/// Static, so the delete reply allocates nothing.
704const true_fragment = "true";
705
706/// One `EKEvent` as Swift's `eventData` map reads it (3304-3312).
707///
708/// The four strings are already `?? ""`-collapsed: nil `location` is `""` on the
709/// wire, not a missing key and not `null`, whichever way `craft.d.ts` declares
710/// it. The strings borrow whatever buffer they were read from — on the native
711/// path an `NSString`'s internal UTF-8, valid for the completion's autorelease
712/// pool — and every one is copied into the JSON before that returns.
713const CalEvent = struct {
714 id: []const u8,
715 title: []const u8,
716 location: []const u8,
717 notes: []const u8,
718 /// `timeIntervalSince1970 * 1000` — fractional milliseconds, not an integer.
719 start_ms: f64,
720 end_ms: f64,
721 all_day: bool,
722};
723
724fn appendJsonString(
725 allocator: std.mem.Allocator,
726 out: *std.ArrayListUnmanaged(u8),
727 s: []const u8,
728) !void {
729 try out.append(allocator, '"');
730 // Titles, locations and notes are user data and will contain `"` and `\` in
731 // the wild. This reply is replayed into the source `evaluateJavaScript:`
732 // parses, so an unescaped quote is a syntax error in the page rather than a
733 // wrong field.
734 try bridge_error.appendJsonEscaped(allocator, out, s);
735 try out.append(allocator, '"');
736}
737
738/// One `f64` as a JSON number.
739///
740/// `{d}` renders decimal notation, never scientific, and a finite `f64` can need
741/// up to `std.fmt.float.bufferSize(.decimal, f64)` (347) bytes. Non-finite is
742/// refused rather than printed: `inf` and `nan` are not JSON, Swift's own
743/// `JSONSerialization` refuses them too, and printing them would produce a
744/// syntax error inside the reply script with nothing to point at.
745///
746/// A benign divergence, stated rather than hidden: Zig's shortest-round-trip
747/// `{d}` and `JSONSerialization`'s renderer can print different digit strings
748/// for the same `f64`. Both parse back to the same JavaScript number.
749fn appendNumber(
750 allocator: std.mem.Allocator,
751 out: *std.ArrayListUnmanaged(u8),
752 value: f64,
753) !void {
754 if (!std.math.isFinite(value)) return bridge_error.BridgeError.InvalidParameter;
755 var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
756 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
757}
758
759/// The `getCalendarEvents` reply: a bare array of seven-key objects.
760///
761/// Key order is fixed at id, title, location, notes, startDate, endDate,
762/// isAllDay — Swift's literal order, which as a `Dictionary` it does not
763/// actually preserve. A caller cannot depend on order, but a *test* can only pin
764/// bytes that are deterministic.
765///
766/// An empty list is `[]` and **resolves**. `events.map` over nothing is `[]` in
767/// Swift, so turning "no events in that range" into an error would break a path
768/// the shim answers successfully.
769fn shapeEvents(allocator: std.mem.Allocator, events: []const CalEvent) ![]u8 {
770 var out: std.ArrayListUnmanaged(u8) = .empty;
771 errdefer out.deinit(allocator);
772
773 try out.append(allocator, '[');
774 for (events, 0..) |event, i| {
775 if (i != 0) try out.append(allocator, ',');
776 try out.appendSlice(allocator, "{\"id\":");
777 try appendJsonString(allocator, &out, event.id);
778 try out.appendSlice(allocator, ",\"title\":");
779 try appendJsonString(allocator, &out, event.title);
780 try out.appendSlice(allocator, ",\"location\":");
781 try appendJsonString(allocator, &out, event.location);
782 try out.appendSlice(allocator, ",\"notes\":");
783 try appendJsonString(allocator, &out, event.notes);
784 try out.appendSlice(allocator, ",\"startDate\":");
785 try appendNumber(allocator, &out, event.start_ms);
786 try out.appendSlice(allocator, ",\"endDate\":");
787 try appendNumber(allocator, &out, event.end_ms);
788 try out.appendSlice(allocator, ",\"isAllDay\":");
789 try out.appendSlice(allocator, if (event.all_day) "true" else "false");
790 try out.append(allocator, '}');
791 }
792 try out.append(allocator, ']');
793
794 return out.toOwnedSlice(allocator);
795}
796
797/// The `createCalendarEvent` reply: a bare JSON string, or bare `null`.
798///
799/// `null` is what a nil `eventIdentifier` reaches the page as through Swift, and
800/// it is what is sent here — even though `craft-bridge.js:80`'s `payload || {}`
801/// turns it into `{}` on the Zig route. See the module comment: rejecting a save
802/// that succeeded would be the worse lie, and `""` is swallowed by `|| {}` just
803/// the same while additionally being false about the identifier.
804fn shapeCreatedId(allocator: std.mem.Allocator, identifier: ?[]const u8) ![]u8 {
805 var out: std.ArrayListUnmanaged(u8) = .empty;
806 errdefer out.deinit(allocator);
807
808 if (identifier) |id| {
809 try appendJsonString(allocator, &out, id);
810 } else {
811 try out.appendSlice(allocator, "null");
812 }
813
814 return out.toOwnedSlice(allocator);
815}
816
817/// Swift's `startDate! / 1000` — the wire carries milliseconds and
818/// `NSTimeInterval` is seconds. One function so the division has one place to be
819/// wrong in, and one test.
820fn secondsFromMilliseconds(ms: f64) f64 {
821 return ms / 1000.0;
822}
823
824// =============================================================================
825// Framework constants — transcribed, so pinned by the tests below. This is the
826// `bridge_mobile_permissions.zig` convention: a constant nobody can look up at
827// runtime is a constant a test has to hold.
828// =============================================================================
829
830/// `EKEntityType`: `NSUInteger`. event = 0, reminder = 1. Already pinned once at
831/// `bridge_mobile_permissions.zig:497`; pinned again here because a wrong value
832/// would request access to reminders and report it as calendar access.
833const ek_entity_type_event: c_ulong = 0;
834
835/// `EKSpan`: `NSInteger`. `EKSpanThisEvent` = 0, `EKSpanFutureEvents` = 1.
836/// Swift passes `.thisEvent` to both `save` and `remove`; the future-events span
837/// would edit or delete every occurrence of a recurring event.
838const ek_span_this_event: c_long = 0;
839
840/// `NSCalendarUnitMonth` = `1 << 3` — `NSCalendarUnit`, an `NSUInteger`.
841/// Nothing in this repo pinned it before. It is what makes
842/// `Calendar.current.date(byAdding: .month, value: 1, to: Date())` (3298) a
843/// **calendar month in the user's calendar and time zone** — 28, 29, 30 or 31
844/// days depending on when it is called. "+30 days" is a different default range
845/// and is not substituted.
846const ns_calendar_unit_month: c_ulong = 1 << 3;
847
848/// The `options:` argument of `dateByAddingUnit:value:toDate:options:`.
849/// `Calendar.current.date(byAdding:value:to:)` passes no
850/// `NSCalendarOptions`, which is 0.
851const ns_calendar_options_none: c_ulong = 0;
852
853/// `NSUTF8StringEncoding`. Used only to ask a string how long it really is, so a
854/// NUL-truncated read can be told from a short string.
855const ns_utf8_string_encoding: c_ulong = 4;
856
857/// The Info.plist key `packages/ios/src/index.ts:190` writes if and only if
858/// `config.enableCalendar`.
859const key_calendars_usage = "NSCalendarsUsageDescription";
860
861// =============================================================================
862// Objective-C: everything looked up once at dispatch, then a completion that
863// cannot look anything up.
864// =============================================================================
865
866fn selector(name: [*:0]const u8) !Id {
867 if (!is_darwin) return error.UnsupportedPlatform;
868 return objc.sel_registerName(name) orelse error.SelectorNotFound;
869}
870
871/// The main bundle's Info.plist value for `key`, or null when it has none.
872///
873/// Errors rather than answering null when the runtime itself will not cooperate:
874/// "there is no NSBundle class" and "this app was not built with calendar
875/// enabled" are different facts, and collapsing them would blame the app's
876/// configuration for a broken process.
877fn infoPlistValue(comptime key: [*:0]const u8) !Id {
878 if (!is_darwin) return error.UnsupportedPlatform;
879
880 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
881 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
882 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
883
884 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
885 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
886 return error.SelectorNotFound;
887 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
888
889 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
890 return error.SelectorNotFound;
891 return objc.msgSendId1(bundle, sel_lookup, ns_key);
892}
893
894/// Refuse when the app was not built with `enableCalendar`.
895///
896/// Checked first, matching Swift's `if config.enableCalendar` guarding all three
897/// cases, and — for the two actions that request access — checked **before** the
898/// store is touched, because `requestAccessToEntityType:` without this key raises
899/// `NSInternalInconsistencyException`, which from Zig is an uncatchable SIGABRT.
900/// That is the difference from `bridge_mobile_contactpicker.zig`'s gate, which
901/// guards a picker needing no authorization at all: here the key is a genuine
902/// precondition of the API and not merely evidence of a config flag.
903/// `deleteCalendarEvent` requests nothing, so for that one action it *is* only
904/// evidence of the flag — which is why it is still checked.
905fn requireCalendarConfigured(action: []const u8) !void {
906 if (!is_darwin) return error.UnsupportedPlatform;
907
908 if ((try infoPlistValue(key_calendars_usage)) == null) {
909 std.log.warn(
910 "{s} refused: Info.plist has no {s}, so this app was not built with calendar " ++
911 "enabled (and requesting access without it would terminate the process)",
912 .{ action, key_calendars_usage },
913 );
914 return bridge_error.BridgeError.PermissionDenied;
915 }
916}
917
918/// Everything the completion needs, resolved while a synchronous error can still
919/// reach the page.
920///
921/// The completion runs long after the dispatch frame is gone, where a failed
922/// `sel_registerName` could only be logged. Resolving here turns that whole class
923/// of failure into an ordinary rejection.
924///
925/// Four *class* objects ride along for the same reason. `EKEvent` is guarded
926/// with a message naming EventKit.framework, because the fix is a link line
927/// rather than anything in this file — the zig-slice fixture
928/// (`packages/ios/fixtures/zig-slice/build-and-run.sh:65`) links only UIKit,
929/// WebKit, Foundation and Security, so this is exactly what it hits.
930const Sels = struct {
931 // Classes used as receivers of class methods.
932 event_class: Id,
933 date_class: Id,
934 calendar_class: Id,
935 string_class: Id,
936
937 // EKEventStore
938 request_access: Id,
939 predicate_for_events: Id,
940 events_matching: Id,
941 default_calendar: Id,
942 save_event: Id,
943 event_with_identifier: Id,
944 remove_event: Id,
945
946 // EKEvent (class method, then getters, then setters)
947 event_with_event_store: Id,
948 event_identifier: Id,
949 title: Id,
950 location: Id,
951 notes: Id,
952 start_date: Id,
953 end_date: Id,
954 /// The property is `allDay` with `getter=isAllDay`; the selector really is
955 /// `isAllDay` and the setter really is `setAllDay:`.
956 is_all_day: Id,
957 set_title: Id,
958 set_location: Id,
959 set_notes: Id,
960 set_start_date: Id,
961 set_end_date: Id,
962 set_all_day: Id,
963 set_calendar: Id,
964
965 // NSDate
966 date_with_interval: Id,
967 time_interval_since_1970: Id,
968 date_now: Id,
969
970 // NSCalendar
971 current_calendar: Id,
972 date_by_adding_unit: Id,
973
974 // NSArray
975 count: Id,
976 object_at: Id,
977
978 // NSString
979 utf8: Id,
980 length_of_bytes: Id,
981 string_with_utf8: Id,
982
983 // NSError, for the log line a BridgeError cannot carry.
984 localized_description: Id,
985
986 fn resolve(action: []const u8) !Sels {
987 if (!is_darwin) return error.UnsupportedPlatform;
988
989 const event_class = objc.objc_getClass("EKEvent") orelse {
990 std.log.err(
991 "{s} refused: this process has no EKEvent; EventKit.framework is not linked",
992 .{action},
993 );
994 return bridge_error.BridgeError.PlatformNotSupported;
995 };
996 const date_class = objc.objc_getClass("NSDate") orelse return error.ClassNotFound;
997 const calendar_class = objc.objc_getClass("NSCalendar") orelse return error.ClassNotFound;
998 const string_class = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
999
1000 return .{
1001 .event_class = event_class,
1002 .date_class = date_class,
1003 .calendar_class = calendar_class,
1004 .string_class = string_class,
1005
1006 .request_access = try selector("requestAccessToEntityType:completion:"),
1007 .predicate_for_events = try selector("predicateForEventsWithStartDate:endDate:calendars:"),
1008 .events_matching = try selector("eventsMatchingPredicate:"),
1009 .default_calendar = try selector("defaultCalendarForNewEvents"),
1010 .save_event = try selector("saveEvent:span:error:"),
1011 .event_with_identifier = try selector("eventWithIdentifier:"),
1012 .remove_event = try selector("removeEvent:span:error:"),
1013
1014 .event_with_event_store = try selector("eventWithEventStore:"),
1015 .event_identifier = try selector("eventIdentifier"),
1016 .title = try selector("title"),
1017 .location = try selector("location"),
1018 .notes = try selector("notes"),
1019 .start_date = try selector("startDate"),
1020 .end_date = try selector("endDate"),
1021 .is_all_day = try selector("isAllDay"),
1022 .set_title = try selector("setTitle:"),
1023 .set_location = try selector("setLocation:"),
1024 .set_notes = try selector("setNotes:"),
1025 .set_start_date = try selector("setStartDate:"),
1026 .set_end_date = try selector("setEndDate:"),
1027 .set_all_day = try selector("setAllDay:"),
1028 .set_calendar = try selector("setCalendar:"),
1029
1030 .date_with_interval = try selector("dateWithTimeIntervalSince1970:"),
1031 .time_interval_since_1970 = try selector("timeIntervalSince1970"),
1032 .date_now = try selector("date"),
1033
1034 .current_calendar = try selector("currentCalendar"),
1035 .date_by_adding_unit = try selector("dateByAddingUnit:value:toDate:options:"),
1036
1037 .count = try selector("count"),
1038 .object_at = try selector("objectAtIndex:"),
1039
1040 .utf8 = try selector("UTF8String"),
1041 .length_of_bytes = try selector("lengthOfBytesUsingEncoding:"),
1042 .string_with_utf8 = try selector("stringWithUTF8String:"),
1043
1044 .localized_description = try selector("localizedDescription"),
1045 };
1046 }
1047};
1048
1049/// The one `EKEventStore`, created on first use and never released.
1050///
1051/// `bridge_mobile_permissions.zig:33-38` names this as the exact reason it left
1052/// calendar's `requestPermission` with the shim: the store has to outlive the
1053/// dispatch frame, because the completion is what answers. Swift keeps one as a
1054/// coordinator property for the life of the app (`CraftApp.swift:406, 452-453`)
1055/// and so does this — a lazily created, never-released singleton, which is the
1056/// same object graph rather than a new mechanism.
1057///
1058/// Creating a store neither prompts nor requires authorization; only
1059/// `requestAccessToEntityType:` does, and the Info.plist gate runs before this.
1060var event_store: Id = null;
1061var store_mutex: compat_mutex.Mutex = .{};
1062
1063fn ensureStore(action: []const u8) !Id {
1064 if (!is_darwin) return error.UnsupportedPlatform;
1065
1066 store_mutex.lock();
1067 defer store_mutex.unlock();
1068
1069 if (event_store) |existing| return existing;
1070
1071 const StoreClass = objc.objc_getClass("EKEventStore") orelse {
1072 std.log.err(
1073 "{s} refused: this process has no EKEventStore; EventKit.framework is not linked",
1074 .{action},
1075 );
1076 return bridge_error.BridgeError.PlatformNotSupported;
1077 };
1078
1079 const store = (try objc.allocInit(StoreClass)) orelse return error.NativeCallFailed;
1080 event_store = store;
1081 return store;
1082}
1083
1084/// `+[NSString stringWithUTF8String:]` over caller bytes.
1085///
1086/// The classes and selectors come from `Sels`, resolved at dispatch, so this
1087/// performs no lookups and can be called from the completion. A nil result means
1088/// the bytes were not valid UTF-8 — refused, never substituted.
1089fn makeString(sels: Sels, allocator: std.mem.Allocator, text: []const u8) !Id {
1090 if (!is_darwin) return error.UnsupportedPlatform;
1091
1092 const z = try memory.dupeZ(allocator, u8, text);
1093 defer allocator.free(z);
1094
1095 return objc.msgSendId1(sels.string_class, sels.string_with_utf8, z.ptr) orelse
1096 error.NativeCallFailed;
1097}
1098
1099/// One `NSString` as bytes, or a refusal.
1100///
1101/// The truncation check is why this is not one line. `UTF8String` hands back a
1102/// NUL-terminated buffer, so a value containing U+0000 — which
1103/// `JSONSerialization` would have preserved on the Swift side — reads back as its
1104/// prefix. `lengthOfBytesUsingEncoding:` counts the real encoded bytes, so a
1105/// mismatch is exactly that case, and reporting a prefix as the whole title is
1106/// the one thing worse than failing.
1107///
1108/// The returned slice borrows the string's internal buffer, valid for the
1109/// current autorelease pool; every caller copies it into the reply before
1110/// returning.
1111fn readString(ns: Id, sels: Sels) ![]const u8 {
1112 if (!is_darwin) return error.UnsupportedPlatform;
1113 if (ns == null) return error.NilNativeString;
1114
1115 const Utf8Fn = *const fn (Id, Id) callconv(.c) ?[*:0]const u8;
1116 const utf8: Utf8Fn = @ptrCast(&objc.objc_msgSend);
1117 const cstr = utf8(ns, sels.utf8) orelse return error.NativeCallFailed;
1118 const text = std.mem.span(cstr);
1119
1120 const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong;
1121 const len_of: LenFn = @ptrCast(&objc.objc_msgSend);
1122 if (len_of(ns, sels.length_of_bytes, ns_utf8_string_encoding) != text.len) {
1123 return error.EmbeddedNulInNativeString;
1124 }
1125
1126 return text;
1127}
1128
1129/// `readString` for the four `EKEvent` properties that are genuinely nullable.
1130/// Null is not a failure here — `title`, `location`, `notes` and
1131/// `eventIdentifier` are all `nullable` and Swift coalesces every one of them.
1132fn readOptionalString(ns: Id, sels: Sels) !?[]const u8 {
1133 if (ns == null) return null;
1134 return try readString(ns, sels);
1135}
1136
1137/// The `NSError`'s `localizedDescription`, for a log line.
1138///
1139/// Never for a reply: `BridgeError` carries a fixed code and message, so a
1140/// localised EventKit string has nowhere to go on the wire. Saying it in the log
1141/// is the honest half of that, and claiming it reached the page would be the
1142/// dishonest one.
1143fn errorText(err: Id, sels: Sels) ?[]const u8 {
1144 if (!is_darwin) return null;
1145 if (err == null) return null;
1146
1147 const description = objc.msgSendId(err, sels.localized_description) orelse return null;
1148 return readString(description, sels) catch null;
1149}
1150
1151fn logNativeError(comptime prefix: []const u8, err: Id, sels: Sels) void {
1152 if (errorText(err, sels)) |text| {
1153 std.log.err(prefix ++ ": {s}", .{text});
1154 } else {
1155 // Possible in principle, and said rather than asserted: `NO` with a nil
1156 // `NSError` gets `NATIVE_CALL_FAILED` and a log line admitting there was
1157 // no message, instead of a message that was never received.
1158 std.log.err(prefix ++ " (no NSError was provided)", .{});
1159 }
1160}
1161
1162/// `+[NSDate dateWithTimeIntervalSince1970:]` on the wire's milliseconds.
1163fn dateFromMilliseconds(sels: Sels, ms: f64) Id {
1164 if (!is_darwin) return null;
1165
1166 const DateFn = *const fn (Id, Id, f64) callconv(.c) Id;
1167 const make: DateFn = @ptrCast(&objc.objc_msgSend);
1168 return make(sels.date_class, sels.date_with_interval, secondsFromMilliseconds(ms));
1169}
1170
1171/// `+[NSDate date]` — Swift's `Date()`, evaluated inside the completion exactly
1172/// where Swift evaluates it.
1173fn nowDate(sels: Sels) Id {
1174 if (!is_darwin) return null;
1175 return objc.msgSendId(sels.date_class, sels.date_now);
1176}
1177
1178/// `Calendar.current.date(byAdding: .month, value: 1, to: Date())!` (3298).
1179///
1180/// A **calendar month**, not thirty days: the length depends on the month and on
1181/// the user's calendar and time zone. Swift force-unwraps the result; here a nil
1182/// is checked, which is the same hazard `CraftApp.swift:3301` has with
1183/// `predicate!` and the reason neither force-unwrap is translated.
1184fn defaultEndDate(sels: Sels) Id {
1185 if (!is_darwin) return null;
1186
1187 const calendar = objc.msgSendId(sels.calendar_class, sels.current_calendar) orelse return null;
1188 const now = nowDate(sels) orelse return null;
1189
1190 const AddFn = *const fn (Id, Id, c_ulong, c_long, Id, c_ulong) callconv(.c) Id;
1191 const add: AddFn = @ptrCast(&objc.objc_msgSend);
1192 return add(
1193 calendar,
1194 sels.date_by_adding_unit,
1195 ns_calendar_unit_month,
1196 1,
1197 now,
1198 ns_calendar_options_none,
1199 );
1200}
1201
1202/// Read one `EKEvent` the way Swift's `eventData` map reads it (3304-3312).
1203///
1204/// Every string is `?? ""`-collapsed, including the identifier — that is the
1205/// fetch shape, and it is *not* the create shape, where a nil identifier is bare
1206/// `null`. The two are deliberately not shared.
1207///
1208/// `startDate` and `endDate` are the exception: Swift force-unwraps them
1209/// (`event.startDate.timeIntervalSince1970`) and would crash on nil. There is no
1210/// `?? ""` to copy and no honest substitute — `0` would be an event dated 1 Jan
1211/// 1970 reported as real — so a nil date refuses the whole reply.
1212fn readEvent(event: Id, sels: Sels) !CalEvent {
1213 if (!is_darwin) return error.UnsupportedPlatform;
1214 if (event == null) return error.NativeCallFailed;
1215
1216 const DoubleFn = *const fn (Id, Id) callconv(.c) f64;
1217 const send_double: DoubleFn = @ptrCast(&objc.objc_msgSend);
1218 const BoolFn = *const fn (Id, Id) callconv(.c) bool;
1219 const send_bool: BoolFn = @ptrCast(&objc.objc_msgSend);
1220
1221 const start = objc.msgSendId(event, sels.start_date) orelse {
1222 logEventMissingDate(event, sels, "startDate");
1223 return error.EventHasNoStartDate;
1224 };
1225 const end = objc.msgSendId(event, sels.end_date) orelse {
1226 logEventMissingDate(event, sels, "endDate");
1227 return error.EventHasNoEndDate;
1228 };
1229
1230 return .{
1231 .id = (try readOptionalString(objc.msgSendId(event, sels.event_identifier), sels)) orelse "",
1232 .title = (try readOptionalString(objc.msgSendId(event, sels.title), sels)) orelse "",
1233 .location = (try readOptionalString(objc.msgSendId(event, sels.location), sels)) orelse "",
1234 .notes = (try readOptionalString(objc.msgSendId(event, sels.notes), sels)) orelse "",
1235 .start_ms = send_double(start, sels.time_interval_since_1970) * 1000.0,
1236 .end_ms = send_double(end, sels.time_interval_since_1970) * 1000.0,
1237 .all_day = send_bool(event, sels.is_all_day),
1238 };
1239}
1240
1241/// Name the event a nil date refused, which is the only thing that makes the
1242/// refusal actionable: "one of your events has no start date" cannot be looked
1243/// up, and `getCalendarEvents` fails as a whole rather than per row.
1244///
1245/// Best-effort on the id itself. An event broken enough to have a nil
1246/// `startDate` may have an unreadable `eventIdentifier` too, and the *cause*
1247/// matters more than the id, so an unreadable one degrades to a placeholder
1248/// rather than replacing the log line with a different failure.
1249fn logEventMissingDate(event: Id, sels: Sels, comptime which: []const u8) void {
1250 if (!is_darwin) return;
1251
1252 const id_ns = objc.msgSendId(event, sels.event_identifier);
1253 const id_text = (readOptionalString(id_ns, sels) catch null) orelse "<unreadable>";
1254 std.log.err(
1255 "getCalendarEvents: event '{s}' has no " ++ which ++ "; refusing the whole reply " ++
1256 "rather than dating it 1970",
1257 .{id_text},
1258 );
1259}
1260
1261/// Walk `NSArray<EKEvent *>` into `CalEvent` entries.
1262///
1263/// A nil array is refused rather than answered `[]`. `events(matching:)` returns
1264/// a non-optional `[EKEvent]`; Swift's `?? []` guards `self?` being nil, not the
1265/// array, so nil here would mean the framework broke its own contract and "there
1266/// are no events in that range" is a claim with no basis.
1267fn readEvents(allocator: std.mem.Allocator, array: Id, sels: Sels) ![]CalEvent {
1268 if (!is_darwin) return error.UnsupportedPlatform;
1269 if (array == null) return error.NativeCallFailed;
1270
1271 const CountFn = *const fn (Id, Id) callconv(.c) c_ulong;
1272 const count_of: CountFn = @ptrCast(&objc.objc_msgSend);
1273 const total = count_of(array, sels.count);
1274
1275 var out: std.ArrayListUnmanaged(CalEvent) = .empty;
1276 errdefer out.deinit(allocator);
1277
1278 var i: c_ulong = 0;
1279 while (i < total) : (i += 1) {
1280 const event = objc.msgSendId1(array, sels.object_at, i) orelse return error.NativeCallFailed;
1281 try out.append(allocator, try readEvent(event, sels));
1282 }
1283
1284 return out.toOwnedSlice(allocator);
1285}
1286
1287// =============================================================================
1288// The per-slot completion block, and the side table that gives it a ticket.
1289//
1290// A global block captures nothing, which is what makes `Block_copy` on it the
1291// identity function and removes every lifetime question. The price is that the
1292// block knows only its own slot index, baked in at comptime — so the ticket, the
1293// selectors resolved at dispatch, the store, and the request's own parameters
1294// are looked up here.
1295// =============================================================================
1296
1297/// What the completion still has to do, and everything it needs to do it.
1298const Request = union(enum) {
1299 fetch: Range,
1300 create: NewEvent,
1301
1302 fn deinit(self: Request, allocator: std.mem.Allocator) void {
1303 switch (self) {
1304 .fetch => {},
1305 .create => |event| event.deinit(allocator),
1306 }
1307 }
1308};
1309
1310const PendingCall = struct {
1311 ticket: ios_async.Ticket,
1312 sels: Sels,
1313 store: Id,
1314 request: Request,
1315};
1316
1317/// One entry per `ios_async` slot, not one entry total.
1318///
1319/// `bridge_mobile_contactpicker.zig`'s single `pending` is right for a modal
1320/// picker, where a second call cannot happen while the first is on screen.
1321/// Nothing here is modal: two `getCalendarEvents` in flight is an ordinary page,
1322/// and a single slot would refuse the second for no reason.
1323var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
1324var pending_mutex: compat_mutex.Mutex = .{};
1325
1326/// Record the call a slot's block will answer. The slot is leased exclusively by
1327/// this ticket, so the entry is ours to write.
1328fn publishPendingCall(ticket: ios_async.Ticket, sels: Sels, store: Id, request: Request) void {
1329 pending_mutex.lock();
1330 defer pending_mutex.unlock();
1331 pending_calls[ticket.index] = .{
1332 .ticket = ticket,
1333 .sels = sels,
1334 .store = store,
1335 .request = request,
1336 };
1337}
1338
1339/// Read and clear a slot's entry. Clearing is what makes a second fire of the
1340/// same completion a no-op rather than a second reply — and, here, rather than a
1341/// double free of the pending event's strings.
1342fn takePendingCall(index: u5) ?PendingCall {
1343 pending_mutex.lock();
1344 defer pending_mutex.unlock();
1345 const call = pending_calls[index];
1346 pending_calls[index] = null;
1347 return call;
1348}
1349
1350const BlockDescriptor = extern struct {
1351 reserved: c_ulong = 0,
1352 size: c_ulong,
1353};
1354
1355/// `void (^)(BOOL granted, NSError *error)` — the shape of
1356/// `-[EKEventStore requestAccessToEntityType:completion:]`.
1357///
1358/// `ios_async.boolErrorBlock` has this exact invoke shape and is still the wrong
1359/// block for two of these three actions: it replies the strings
1360/// `"granted"`/`"denied"`, and the page is owed the *events* or the new
1361/// *identifier*. A grant is the beginning of the work here, not the answer.
1362const AccessBlock = extern struct {
1363 isa: ?*anyopaque,
1364 flags: c_int,
1365 reserved: c_int = 0,
1366 invoke: *const anyopaque,
1367 descriptor: *const BlockDescriptor,
1368};
1369
1370/// 1 << 28. `Block_copy` on a global block returns the same pointer, so a
1371/// module-level block can be handed to an API that escapes it with no heap copy,
1372/// no copy/dispose helpers, and no descriptor lifetime.
1373const BLOCK_IS_GLOBAL: c_int = 1 << 28;
1374
1375const access_block_descriptor = BlockDescriptor{ .size = @sizeOf(AccessBlock) };
1376
1377extern var _NSConcreteGlobalBlock: anyopaque;
1378
1379/// One invoke per slot, comptime-generated so each block knows which slot it is
1380/// without capturing anything.
1381fn makeAccessInvoke(comptime index: u5) *const anyopaque {
1382 const S = struct {
1383 fn invoke(_: *const AccessBlock, granted: bool, err: Id) callconv(.c) void {
1384 accessCompletionFired(index, granted, err);
1385 }
1386 };
1387 return @ptrCast(&S.invoke);
1388}
1389
1390fn makeAccessBlocks() [ios_async.max_in_flight]AccessBlock {
1391 var out: [ios_async.max_in_flight]AccessBlock = undefined;
1392 for (&out, 0..) |*b, i| {
1393 b.* = .{
1394 .isa = &_NSConcreteGlobalBlock,
1395 .flags = BLOCK_IS_GLOBAL,
1396 .invoke = makeAccessInvoke(@intCast(i)),
1397 .descriptor = &access_block_descriptor,
1398 };
1399 }
1400 return out;
1401}
1402
1403var access_blocks: [ios_async.max_in_flight]AccessBlock =
1404 if (is_darwin) makeAccessBlocks() else undefined;
1405
1406fn accessBlock(ticket: ios_async.Ticket) *anyopaque {
1407 return @ptrCast(&access_blocks[ticket.index]);
1408}
1409
1410/// Runs on whatever queue EventKit chose.
1411///
1412/// Nothing replies from here: `evaluateJavaScript:` is main-thread-only *and* the
1413/// `request_context` that names the waiting call is long gone, so every answer
1414/// goes through `ios_async`, which copies the payload, hops to the main queue,
1415/// and replies under the id captured back at dispatch.
1416///
1417/// The blocking EventKit work — `eventsMatchingPredicate:`, `saveEvent:` — is
1418/// deliberately done *here*, on the background queue, which is where Swift does
1419/// it too.
1420fn accessCompletionFired(index: u5, granted: bool, err: Id) void {
1421 if (!is_darwin) return;
1422
1423 const allocator = std.heap.c_allocator;
1424
1425 const call = takePendingCall(index) orelse {
1426 std.log.warn(
1427 "calendar: an access completion fired for slot {d} with no call recorded; ignored " ++
1428 "rather than answered to whoever holds the slot next",
1429 .{index},
1430 );
1431 return;
1432 };
1433 // The pending event's strings belong to this call whichever way it ends.
1434 defer call.request.deinit(allocator);
1435
1436 if (!granted) {
1437 if (errorText(err, call.sels)) |text| {
1438 std.log.warn(
1439 "calendar: access to events was not granted ({s}); rejecting as PERMISSION_DENIED " ++
1440 "(Swift sends CRAFT_ERROR with this text, which BridgeError cannot carry)",
1441 .{text},
1442 );
1443 } else {
1444 std.log.warn(
1445 "calendar: access to events was not granted; rejecting as PERMISSION_DENIED",
1446 .{},
1447 );
1448 }
1449 // A user declining a prompt is not a native call failing — the call
1450 // worked and the answer is "no". `deliverError`'s NATIVE_CALL_FAILED
1451 // would send whoever reads it looking for a bug that is not there.
1452 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.PermissionDenied);
1453 return;
1454 }
1455
1456 switch (call.request) {
1457 .fetch => |range| runFetch(call.ticket, call.sels, call.store, range),
1458 .create => |event| runCreate(call.ticket, call.sels, call.store, event),
1459 }
1460}
1461
1462/// The granted half of `getCalendarEvents` (3297-3315).
1463fn runFetch(ticket: ios_async.Ticket, sels: Sels, store: Id, range: Range) void {
1464 if (!is_darwin) return;
1465
1466 const allocator = std.heap.c_allocator;
1467
1468 const start = (if (range.start_ms) |ms| dateFromMilliseconds(sels, ms) else nowDate(sels)) orelse {
1469 std.log.err("getCalendarEvents: the start date could not be built; rejecting", .{});
1470 ios_async.deliverError(ticket);
1471 return;
1472 };
1473 const end = (if (range.end_ms) |ms| dateFromMilliseconds(sels, ms) else defaultEndDate(sels)) orelse {
1474 std.log.err(
1475 "getCalendarEvents: the end date could not be built (Calendar.current + 1 month " ++
1476 "answered nil); rejecting rather than substituting a range the page did not ask for",
1477 .{},
1478 );
1479 ios_async.deliverError(ticket);
1480 return;
1481 };
1482
1483 const PredicateFn = *const fn (Id, Id, Id, Id, Id) callconv(.c) Id;
1484 const make_predicate: PredicateFn = @ptrCast(&objc.objc_msgSend);
1485 // `calendars: nil` — every calendar, as Swift passes (3300).
1486 const predicate = make_predicate(
1487 store,
1488 sels.predicate_for_events,
1489 start,
1490 end,
1491 @as(Id, null),
1492 ) orelse {
1493 std.log.err("getCalendarEvents: predicateForEventsWithStartDate: answered nil; rejecting", .{});
1494 ios_async.deliverError(ticket);
1495 return;
1496 };
1497
1498 const array = objc.msgSendId1(store, sels.events_matching, predicate);
1499
1500 const events = readEvents(allocator, array, sels) catch |read_err| {
1501 std.log.err(
1502 "getCalendarEvents: could not read the matched events ({}); rejecting rather than " ++
1503 "replying with a list that was not read",
1504 .{read_err},
1505 );
1506 ios_async.deliverError(ticket);
1507 return;
1508 };
1509 defer allocator.free(events);
1510
1511 const json = shapeEvents(allocator, events) catch |shape_err| {
1512 std.log.err("getCalendarEvents: could not shape the reply ({}); rejecting", .{shape_err});
1513 ios_async.deliverError(ticket);
1514 return;
1515 };
1516 defer allocator.free(json);
1517
1518 // An empty range is `[]` and resolves — see `shapeEvents`.
1519 ios_async.deliverJson(ticket, json);
1520}
1521
1522/// The granted half of `createCalendarEvent` (3326-3346).
1523///
1524/// Field for field with Swift, including the two things that look like
1525/// omissions and are not: `location`/`notes` are assigned even when nil (Swift
1526/// assigns the result of `as? String` unconditionally), and `startDate`/`endDate`
1527/// are assigned only when present (Swift's `if let`), which is what lets
1528/// `saveEvent:` fail with "No start date has been set" instead of this file
1529/// inventing a date.
1530fn runCreate(ticket: ios_async.Ticket, sels: Sels, store: Id, new_event: NewEvent) void {
1531 if (!is_darwin) return;
1532
1533 const allocator = std.heap.c_allocator;
1534
1535 const EventFn = *const fn (Id, Id, Id) callconv(.c) Id;
1536 const make_event: EventFn = @ptrCast(&objc.objc_msgSend);
1537 const event = make_event(sels.event_class, sels.event_with_event_store, store) orelse {
1538 std.log.err("createCalendarEvent: +[EKEvent eventWithEventStore:] answered nil; rejecting", .{});
1539 ios_async.deliverError(ticket);
1540 return;
1541 };
1542
1543 const ns_title = makeString(sels, allocator, new_event.title) catch |err| {
1544 std.log.err("createCalendarEvent: the title could not be bridged to NSString ({}); rejecting", .{err});
1545 ios_async.deliverError(ticket);
1546 return;
1547 };
1548 objc.msgSendVoid1(event, sels.set_title, ns_title);
1549
1550 // Assigned even when absent: Swift writes `event.location = data["location"]
1551 // as? String`, which stores nil. Skipping the message would be a different
1552 // call, not a shortcut.
1553 const ns_location: Id = if (new_event.location) |text| makeString(sels, allocator, text) catch |err| {
1554 std.log.err("createCalendarEvent: the location could not be bridged to NSString ({}); rejecting", .{err});
1555 ios_async.deliverError(ticket);
1556 return;
1557 } else null;
1558 objc.msgSendVoid1(event, sels.set_location, ns_location);
1559
1560 const ns_notes: Id = if (new_event.notes) |text| makeString(sels, allocator, text) catch |err| {
1561 std.log.err("createCalendarEvent: the notes could not be bridged to NSString ({}); rejecting", .{err});
1562 ios_async.deliverError(ticket);
1563 return;
1564 } else null;
1565 objc.msgSendVoid1(event, sels.set_notes, ns_notes);
1566
1567 if (new_event.start_ms) |ms| {
1568 const date = dateFromMilliseconds(sels, ms) orelse {
1569 std.log.err("createCalendarEvent: the start date could not be built; rejecting", .{});
1570 ios_async.deliverError(ticket);
1571 return;
1572 };
1573 objc.msgSendVoid1(event, sels.set_start_date, date);
1574 }
1575 if (new_event.end_ms) |ms| {
1576 const date = dateFromMilliseconds(sels, ms) orelse {
1577 std.log.err("createCalendarEvent: the end date could not be built; rejecting", .{});
1578 ios_async.deliverError(ticket);
1579 return;
1580 };
1581 objc.msgSendVoid1(event, sels.set_end_date, date);
1582 }
1583
1584 objc.msgSendVoid1(event, sels.set_all_day, new_event.all_day);
1585
1586 // Unconditional, exactly as Swift's 3338. `defaultCalendarForNewEvents` is
1587 // nullable — a freshly erased simulator, or a device with no writable
1588 // calendar, answers nil — and assigning nil is what lets `saveEvent:` fail
1589 // with "No calendar has been set". Pre-empting that with a synthetic check
1590 // would claim a different cause, and falling back to the first writable
1591 // calendar would write the event somewhere the shim would not.
1592 objc.msgSendVoid1(event, sels.set_calendar, objc.msgSendId(store, sels.default_calendar));
1593
1594 var ns_error: Id = null;
1595 const SaveFn = *const fn (Id, Id, Id, c_long, ?*Id) callconv(.c) bool;
1596 const save: SaveFn = @ptrCast(&objc.objc_msgSend);
1597 if (!save(store, sels.save_event, event, ek_span_this_event, &ns_error)) {
1598 logNativeError("createCalendarEvent: saveEvent:span:error: failed", ns_error, sels);
1599 ios_async.deliverError(ticket);
1600 return;
1601 }
1602
1603 const identifier = readOptionalString(objc.msgSendId(event, sels.event_identifier), sels) catch |err| {
1604 std.log.err(
1605 "createCalendarEvent: the event was saved but its identifier could not be read ({}); " ++
1606 "rejecting rather than replying with an identifier that was not read",
1607 .{err},
1608 );
1609 ios_async.deliverError(ticket);
1610 return;
1611 };
1612
1613 if (identifier == null) {
1614 std.log.warn(
1615 "createCalendarEvent: the event was saved but eventIdentifier was nil; replying " ++
1616 "bare null, which craft-bridge.js's `payload || ` turns into on this route",
1617 .{},
1618 );
1619 }
1620
1621 const json = shapeCreatedId(allocator, identifier) catch |err| {
1622 std.log.err("createCalendarEvent: could not shape the reply ({}); rejecting", .{err});
1623 ios_async.deliverError(ticket);
1624 return;
1625 };
1626 defer allocator.free(json);
1627
1628 ios_async.deliverJson(ticket, json);
1629}
1630
1631// =============================================================================
1632// Tests — host-only.
1633//
1634// Everything that decides page-visible bytes is pure and pinned here: routing in
1635// both directions, every `as? T` coercion and every refusal, the seven keys and
1636// their order, the empty-array resolve, the bare-null create reply, the bare-true
1637// delete reply, escaping, the transcribed constants, and the per-slot side table
1638// that makes two calls in flight possible.
1639//
1640// Nothing here creates an `EKEventStore` or matches a predicate. The one
1641// Objective-C path a host *does* reach is the Info.plist gate, and it is
1642// exercised for real, because the test runner is exactly the process without a
1643// usage description that the gate exists for — the same call
1644// `bridge_mobile_notifications.zig` makes with the bundle identifier.
1645// =============================================================================
1646
1647const testing = std.testing;
1648
1649/// The side table is module state, and a test that left an entry behind would
1650/// make the next one read someone else's call.
1651fn resetPendingForTesting() void {
1652 pending_mutex.lock();
1653 defer pending_mutex.unlock();
1654 pending_calls = @splat(null);
1655}
1656
1657fn fakeTicket(index: u5, generation: u32) ios_async.Ticket {
1658 return .{ .index = index, .generation = generation };
1659}
1660
1661fn fakeSels() Sels {
1662 // Every field is a plain `Id`; nothing below dereferences them, and the
1663 // side-table behaviour under test does not care what they point at.
1664 return std.mem.zeroes(Sels);
1665}
1666
1667// ---------------------------------------------------------------------------
1668// The table, and the dispatch it claims to describe
1669// ---------------------------------------------------------------------------
1670
1671test "the declared actions are the ones the handler serves" {
1672 try testing.expectEqual(@as(usize, 3), capability_actions.len);
1673 try testing.expectEqualStrings(A.get_calendar_events, capability_actions[0].name);
1674 try testing.expectEqualStrings(A.create_calendar_event, capability_actions[1].name);
1675 try testing.expectEqualStrings(A.delete_calendar_event, capability_actions[2].name);
1676
1677 for (capability_actions) |decl| {
1678 // A `.result` whose handler never replies parks the caller on a promise
1679 // with no timeout; a `.none` that is awaited resolves immediately and
1680 // means nothing. Swift resolves or rejects every path, so `.result`.
1681 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1682 // `.unavailable` would make Zig dispatch and then refuse an action the
1683 // shim serves correctly.
1684 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1685 // `.live` with a reason would be a contradiction the manifest shows apps.
1686 try testing.expect(decl.reason == null);
1687 }
1688}
1689
1690test "the action names match the Swift case labels exactly" {
1691 // The conformance ratchet compares this block against the `case "…":` labels
1692 // in `CraftApp.swift` in both directions; a prettier spelling would read as
1693 // Zig serving an action the spec does not have.
1694 try testing.expectEqualStrings("getCalendarEvents", A.get_calendar_events);
1695 try testing.expectEqualStrings("createCalendarEvent", A.create_calendar_event);
1696 try testing.expectEqualStrings("deleteCalendarEvent", A.delete_calendar_event);
1697}
1698
1699test "every declared action is one the dispatcher routes" {
1700 for (capability_actions) |decl| {
1701 if (routeFor(decl.name) == null) {
1702 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1703 return error.DeclaredActionDoesNotRoute;
1704 }
1705 }
1706}
1707
1708test "every route the dispatcher has is a declared action" {
1709 // The other direction: a route with a handler and no declaration is an
1710 // action the page can call and the manifest denies exists. Each declaration
1711 // must claim a *distinct* route — counting alone would let two rows share
1712 // one route while another went undeclared.
1713 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
1714 for (capability_actions) |decl| {
1715 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1716 const slot = @backingInt(route);
1717 if (claimed[slot]) {
1718 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1719 return error.TwoDeclarationsShareARoute;
1720 }
1721 claimed[slot] = true;
1722 }
1723 for (claimed, 0..) |taken, slot| {
1724 if (!taken) {
1725 std.debug.print(
1726 "route {s} has a handler but no capability_actions row\n",
1727 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
1728 );
1729 return error.RouteNotDeclared;
1730 }
1731 }
1732}
1733
1734test "each calendar action routes to its own handler" {
1735 try testing.expectEqual(Route.get_events, routeFor("getCalendarEvents").?);
1736 try testing.expectEqual(Route.create_event, routeFor("createCalendarEvent").?);
1737 try testing.expectEqual(Route.delete_event, routeFor("deleteCalendarEvent").?);
1738}
1739
1740test "an action this module does not serve is refused as UnknownAction" {
1741 // Not any other error: `ios_dispatch.route` reads UnknownAction as "not mine,
1742 // ask the next module" and anything else as a final answer. Getting this
1743 // wrong would make this file swallow another module's action — or the shim's.
1744 var bridge = CalendarBridge.init(testing.allocator);
1745 defer bridge.deinit();
1746
1747 for ([_][]const u8{
1748 // Neighbours in the same tier, and the reminders actions EventKit also
1749 // serves — none of which this module implements.
1750 "getContacts",
1751 "addContact",
1752 "pickContact",
1753 "getReminders",
1754 "createReminder",
1755 "requestPermission",
1756 // Casing and pluralisation are how a real typo arrives, and a miss does
1757 // not fail loudly: the action would quietly fall through to the shim.
1758 "getcalendarevents",
1759 "GetCalendarEvents",
1760 "getCalendarEvent",
1761 "createCalendarEvents",
1762 "deleteCalendarEvents",
1763 "",
1764 }) |action| {
1765 try testing.expectError(
1766 bridge_error.BridgeError.UnknownAction,
1767 bridge.handleMessage(action, "{}"),
1768 );
1769 try testing.expect(routeFor(action) == null);
1770 }
1771}
1772
1773// ---------------------------------------------------------------------------
1774// Payload parsing: getCalendarEvents
1775// ---------------------------------------------------------------------------
1776
1777test "an absent range is absent, so the native side can apply Swift's defaults" {
1778 // `ios_dispatch.payloadOf` hands an absent `d` through as `{}`, which is the
1779 // first case. Both fields must come back null rather than 0 — a zero would be
1780 // 1 Jan 1970 and would fetch a range nobody asked for.
1781 const empty = try parseRange(testing.allocator, "{}");
1782 try testing.expect(empty.start_ms == null);
1783 try testing.expect(empty.end_ms == null);
1784
1785 const other = try parseRange(testing.allocator, "{\"unrelated\":1}");
1786 try testing.expect(other.start_ms == null);
1787 try testing.expect(other.end_ms == null);
1788
1789 // JSON null is the same as absent — `NSNull as? Double` is nil, and Swift
1790 // defaults.
1791 const nulls = try parseRange(testing.allocator, "{\"startDate\":null,\"endDate\":null}");
1792 try testing.expect(nulls.start_ms == null);
1793 try testing.expect(nulls.end_ms == null);
1794}
1795
1796test "a numeric range is carried through in milliseconds, undivided" {
1797 // The wire is milliseconds and `NSTimeInterval` is seconds; the division
1798 // belongs next to the NSDate, not here. A struct holding "seconds" filled
1799 // from a millisecond value is the unit slip nothing catches.
1800 const range = try parseRange(
1801 testing.allocator,
1802 "{\"startDate\":1700000000000,\"endDate\":1700003600000.5}",
1803 );
1804 try testing.expectEqual(@as(f64, 1700000000000), range.start_ms.?);
1805 try testing.expectEqual(@as(f64, 1700003600000.5), range.end_ms.?);
1806}
1807
1808test "a wrong-typed range field is refused rather than silently defaulted" {
1809 // This is the divergence the module comment argues for. Swift's `as? Double`
1810 // answers nil and then uses `Date()` / `Date() + 1 month`, so
1811 // `getEvents('2024-01-01')` resolves with events from a range the page never
1812 // asked for and cannot detect. Refusing is observable.
1813 for ([_][]const u8{
1814 "{\"startDate\":\"2024-01-01\"}",
1815 "{\"endDate\":\"2024-01-01\"}",
1816 "{\"startDate\":[1,2]}",
1817 "{\"endDate\":{\"ms\":1}}",
1818 // The `__NSCFBoolean` wart: whether Foundation would read this as 1.0 is
1819 // genuinely unsettled, and it is nonsense on either reading.
1820 "{\"startDate\":true}",
1821 "{\"endDate\":false}",
1822 }) |body| {
1823 try testing.expectError(
1824 bridge_error.BridgeError.InvalidParameter,
1825 parseRange(testing.allocator, body),
1826 );
1827 }
1828}
1829
1830test "a range literal too wide for i64 is read, and a non-finite one is refused" {
1831 // `std.json` parks both in `.number_string`: one is a number the page really
1832 // sent, the other is not a date at all.
1833 const wide = try parseRange(testing.allocator, "{\"startDate\":123456789012345678901}");
1834 try testing.expectEqual(@as(f64, 123456789012345678901.0), wide.start_ms.?);
1835
1836 try testing.expectError(
1837 bridge_error.BridgeError.InvalidParameter,
1838 parseRange(testing.allocator, "{\"startDate\":1e400}"),
1839 );
1840}
1841
1842test "a body that is not a JSON object is InvalidJSON, never a default range" {
1843 for ([_][]const u8{ "[]", "5", "\"nope\"", "{", "" }) |body| {
1844 try testing.expectError(
1845 bridge_error.BridgeError.InvalidJSON,
1846 parseRange(testing.allocator, body),
1847 );
1848 }
1849}
1850
1851// ---------------------------------------------------------------------------
1852// Payload parsing: createCalendarEvent
1853// ---------------------------------------------------------------------------
1854
1855test "a message with no event object is MissingData, not the shim's silence" {
1856 // Swift's `case` is `if config.enableCalendar, let eventData = body["event"]
1857 // as? [String: Any]`, with no `else` — the page's hand-built promise never
1858 // settles. An explicit rejection is strictly better and is observable.
1859 for ([_][]const u8{ "{}", "{\"title\":\"no wrapper\"}", "{\"event\":null}" }) |body| {
1860 try testing.expectError(
1861 bridge_error.BridgeError.MissingData,
1862 parseNewEvent(testing.allocator, body),
1863 );
1864 }
1865}
1866
1867test "an event that is not an object is InvalidParameter, distinct from absent" {
1868 for ([_][]const u8{
1869 "{\"event\":\"Standup\"}",
1870 "{\"event\":42}",
1871 "{\"event\":[{\"title\":\"a\"}]}",
1872 "{\"event\":true}",
1873 }) |body| {
1874 try testing.expectError(
1875 bridge_error.BridgeError.InvalidParameter,
1876 parseNewEvent(testing.allocator, body),
1877 );
1878 }
1879}
1880
1881test "an empty event object takes Swift's defaults exactly" {
1882 // `title` is `?? ""`; `location` and `notes` are plain `as? String`, so nil;
1883 // `isAllDay` is `?? false`; and both dates are left **unset**, which is what
1884 // makes `saveEvent:` fail with "No start date has been set" instead of this
1885 // file inventing one.
1886 const event = try parseNewEvent(testing.allocator, "{\"event\":{}}");
1887 defer event.deinit(testing.allocator);
1888
1889 try testing.expectEqualStrings("", event.title);
1890 try testing.expect(event.location == null);
1891 try testing.expect(event.notes == null);
1892 try testing.expect(event.start_ms == null);
1893 try testing.expect(event.end_ms == null);
1894 try testing.expect(!event.all_day);
1895}
1896
1897test "every event field the page sends is carried, none of them dropped" {
1898 const event = try parseNewEvent(
1899 testing.allocator,
1900 "{\"event\":{\"title\":\"Standup\",\"location\":\"Room 2\",\"notes\":\"bring laptop\"," ++
1901 "\"startDate\":1700000000000,\"endDate\":1700003600000,\"isAllDay\":true}}",
1902 );
1903 defer event.deinit(testing.allocator);
1904
1905 try testing.expectEqualStrings("Standup", event.title);
1906 try testing.expectEqualStrings("Room 2", event.location.?);
1907 try testing.expectEqualStrings("bring laptop", event.notes.?);
1908 try testing.expectEqual(@as(f64, 1700000000000), event.start_ms.?);
1909 try testing.expectEqual(@as(f64, 1700003600000), event.end_ms.?);
1910 try testing.expect(event.all_day);
1911}
1912
1913test "an explicitly null location or notes is nil, which is not the empty string" {
1914 // Three states on the wire collapse to two here, exactly as `as? String`
1915 // collapses them: nil sets the EKEvent property to nil, and `""` sets it to
1916 // an empty string. They are different events.
1917 const nulled = try parseNewEvent(
1918 testing.allocator,
1919 "{\"event\":{\"location\":null,\"notes\":null}}",
1920 );
1921 defer nulled.deinit(testing.allocator);
1922 try testing.expect(nulled.location == null);
1923 try testing.expect(nulled.notes == null);
1924
1925 const empty = try parseNewEvent(
1926 testing.allocator,
1927 "{\"event\":{\"location\":\"\",\"notes\":\"\"}}",
1928 );
1929 defer empty.deinit(testing.allocator);
1930 try testing.expectEqualStrings("", empty.location.?);
1931 try testing.expectEqualStrings("", empty.notes.?);
1932}
1933
1934test "a wrong-typed event string is refused rather than silently defaulted" {
1935 for ([_][]const u8{
1936 "{\"event\":{\"title\":42}}",
1937 "{\"event\":{\"title\":true}}",
1938 "{\"event\":{\"location\":[\"Room 2\"]}}",
1939 "{\"event\":{\"notes\":{\"text\":\"x\"}}}",
1940 }) |body| {
1941 try testing.expectError(
1942 bridge_error.BridgeError.InvalidParameter,
1943 parseNewEvent(testing.allocator, body),
1944 );
1945 }
1946}
1947
1948test "a wrong-typed nested date is refused before any framework call" {
1949 // Swift's `if let … as? Double` leaves the property unset and `saveEvent:`
1950 // rejects with "No start date has been set". Both end in a rejection; this
1951 // one names the actual cause and happens before EventKit is touched.
1952 for ([_][]const u8{
1953 "{\"event\":{\"startDate\":\"2024-01-01T09:00:00Z\"}}",
1954 "{\"event\":{\"endDate\":\"2024-01-01T10:00:00Z\"}}",
1955 "{\"event\":{\"startDate\":1e400}}",
1956 }) |body| {
1957 try testing.expectError(
1958 bridge_error.BridgeError.InvalidParameter,
1959 parseNewEvent(testing.allocator, body),
1960 );
1961 }
1962}
1963
1964test "isAllDay accepts exactly what NSNumber-as-Bool accepts" {
1965 // `as? Bool` on a bridged NSNumber succeeds for a real boolean and for the
1966 // numbers 0 and 1, and for nothing else. `2` is truthy in JavaScript and is
1967 // still not a Bool.
1968 const cases = [_]struct { body: []const u8, expected: bool }{
1969 .{ .body = "{\"event\":{\"isAllDay\":true}}", .expected = true },
1970 .{ .body = "{\"event\":{\"isAllDay\":false}}", .expected = false },
1971 .{ .body = "{\"event\":{\"isAllDay\":1}}", .expected = true },
1972 .{ .body = "{\"event\":{\"isAllDay\":0}}", .expected = false },
1973 .{ .body = "{\"event\":{\"isAllDay\":1.0}}", .expected = true },
1974 .{ .body = "{\"event\":{\"isAllDay\":0.0}}", .expected = false },
1975 .{ .body = "{\"event\":{\"isAllDay\":null}}", .expected = false },
1976 };
1977 for (cases) |case| {
1978 const event = try parseNewEvent(testing.allocator, case.body);
1979 defer event.deinit(testing.allocator);
1980 try testing.expectEqual(case.expected, event.all_day);
1981 }
1982
1983 for ([_][]const u8{
1984 "{\"event\":{\"isAllDay\":2}}",
1985 "{\"event\":{\"isAllDay\":0.5}}",
1986 "{\"event\":{\"isAllDay\":\"yes\"}}",
1987 "{\"event\":{\"isAllDay\":[]}}",
1988 }) |body| {
1989 try testing.expectError(
1990 bridge_error.BridgeError.InvalidParameter,
1991 parseNewEvent(testing.allocator, body),
1992 );
1993 }
1994}
1995
1996test "an embedded NUL in an event string is refused rather than truncated" {
1997 // `stringWithUTF8String:` stops at the NUL, so carrying this across would
1998 // create an event titled "Stand" and report success. Swift's `as? String`
1999 // keeps the whole thing.
2000 for ([_][]const u8{
2001 "{\"event\":{\"title\":\"Stand\\u0000up\"}}",
2002 "{\"event\":{\"location\":\"Room\\u00002\"}}",
2003 "{\"event\":{\"notes\":\"a\\u0000b\"}}",
2004 }) |body| {
2005 try testing.expectError(
2006 bridge_error.BridgeError.InvalidParameter,
2007 parseNewEvent(testing.allocator, body),
2008 );
2009 }
2010}
2011
2012// ---------------------------------------------------------------------------
2013// Payload parsing: deleteCalendarEvent
2014// ---------------------------------------------------------------------------
2015
2016test "an eventId that is absent is MissingData and one that is mistyped is InvalidParameter" {
2017 for ([_][]const u8{ "{}", "{\"id\":\"E1\"}", "{\"eventId\":null}" }) |body| {
2018 try testing.expectError(
2019 bridge_error.BridgeError.MissingData,
2020 parseEventId(testing.allocator, body),
2021 );
2022 }
2023 for ([_][]const u8{
2024 "{\"eventId\":42}",
2025 "{\"eventId\":true}",
2026 "{\"eventId\":[\"E1\"]}",
2027 // A NUL would make `eventWithIdentifier:` look up a different string
2028 // than the page sent.
2029 "{\"eventId\":\"E\\u00001\"}",
2030 }) |body| {
2031 try testing.expectError(
2032 bridge_error.BridgeError.InvalidParameter,
2033 parseEventId(testing.allocator, body),
2034 );
2035 }
2036}
2037
2038test "an empty eventId is accepted, because the shim accepts it and answers not-found" {
2039 // Refusing it here would answer a different question than the shim answers:
2040 // `event(withIdentifier: "")` is nil, which is "Event not found".
2041 const id = try parseEventId(testing.allocator, "{\"eventId\":\"\"}");
2042 defer testing.allocator.free(id);
2043 try testing.expectEqualStrings("", id);
2044}
2045
2046test "an eventId is copied, so it survives the parse tree being freed" {
2047 const id = try parseEventId(testing.allocator, "{\"eventId\":\"E-1234-ABCD\"}");
2048 defer testing.allocator.free(id);
2049 try testing.expectEqualStrings("E-1234-ABCD", id);
2050}
2051
2052// ---------------------------------------------------------------------------
2053// Reply shaping
2054// ---------------------------------------------------------------------------
2055
2056const sample_event = CalEvent{
2057 .id = "E1",
2058 .title = "Standup",
2059 .location = "Room 2",
2060 .notes = "bring laptop",
2061 .start_ms = 1700000000000,
2062 .end_ms = 1700003600000,
2063 .all_day = false,
2064};
2065
2066test "one event carries all seven keys, in a fixed order, as a bare array" {
2067 const json = try shapeEvents(testing.allocator, &.{sample_event});
2068 defer testing.allocator.free(json);
2069
2070 try testing.expectEqualStrings(
2071 "[{\"id\":\"E1\",\"title\":\"Standup\",\"location\":\"Room 2\"," ++
2072 "\"notes\":\"bring laptop\",\"startDate\":1700000000000," ++
2073 "\"endDate\":1700003600000,\"isAllDay\":false}]",
2074 json,
2075 );
2076}
2077
2078test "an event with no location or notes still carries both keys, as empty strings" {
2079 // `craft.d.ts:1174-1183` declares them optional; the runtime emits
2080 // `event.location ?? ""` unconditionally, and the runtime is the contract. A
2081 // page reading `e.location.length` must not meet undefined.
2082 var event = sample_event;
2083 event.location = "";
2084 event.notes = "";
2085
2086 const json = try shapeEvents(testing.allocator, &.{event});
2087 defer testing.allocator.free(json);
2088
2089 try testing.expect(std.mem.indexOf(u8, json, "\"location\":\"\"") != null);
2090 try testing.expect(std.mem.indexOf(u8, json, "\"notes\":\"\"") != null);
2091 try testing.expect(std.mem.indexOf(u8, json, "null") == null);
2092}
2093
2094test "an empty result is [] and resolves, rather than being an error" {
2095 const json = try shapeEvents(testing.allocator, &.{});
2096 defer testing.allocator.free(json);
2097 try testing.expectEqualStrings("[]", json);
2098}
2099
2100test "several events are separated, and every one is complete" {
2101 var second = sample_event;
2102 second.id = "E2";
2103 second.title = "Retro";
2104 second.all_day = true;
2105
2106 const json = try shapeEvents(testing.allocator, &.{ sample_event, second });
2107 defer testing.allocator.free(json);
2108
2109 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
2110 defer parsed.deinit();
2111
2112 const array = parsed.value.array;
2113 try testing.expectEqual(@as(usize, 2), array.items.len);
2114 for (array.items) |item| {
2115 const object = item.object;
2116 try testing.expectEqual(@as(usize, 7), object.count());
2117 for ([_][]const u8{ "id", "title", "location", "notes", "startDate", "endDate", "isAllDay" }) |key| {
2118 try testing.expect(object.get(key) != null);
2119 }
2120 }
2121 try testing.expectEqualStrings("E2", array.items[1].object.get("id").?.string);
2122 try testing.expect(array.items[1].object.get("isAllDay").?.bool);
2123}
2124
2125test "isAllDay is a JSON boolean, not the string \"true\"" {
2126 // `"false"` is truthy in JavaScript, so a quoted flag would make every timed
2127 // event read as all-day.
2128 var event = sample_event;
2129 event.all_day = true;
2130
2131 const json = try shapeEvents(testing.allocator, &.{event});
2132 defer testing.allocator.free(json);
2133
2134 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
2135 defer parsed.deinit();
2136 try testing.expectEqual(std.json.Value.bool, std.meta.activeTag(parsed.value.array.items[0].object.get("isAllDay").?));
2137}
2138
2139test "user text is escaped rather than breaking the reply script" {
2140 // Titles, locations and notes are user data and will contain quotes and
2141 // backslashes in the wild. The reply is replayed into the source
2142 // `evaluateJavaScript:` parses, so an unescaped quote is a syntax error in
2143 // the page rather than a wrong field.
2144 var event = sample_event;
2145 event.id = "E\"1";
2146 event.title = "1:1 with \"Sam\"";
2147 event.location = "C:\\Rooms\\2";
2148 event.notes = "line one\nline two\ttabbed";
2149
2150 const json = try shapeEvents(testing.allocator, &.{event});
2151 defer testing.allocator.free(json);
2152
2153 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
2154 defer parsed.deinit();
2155
2156 const object = parsed.value.array.items[0].object;
2157 try testing.expectEqualStrings("E\"1", object.get("id").?.string);
2158 try testing.expectEqualStrings("1:1 with \"Sam\"", object.get("title").?.string);
2159 try testing.expectEqualStrings("C:\\Rooms\\2", object.get("location").?.string);
2160 try testing.expectEqualStrings("line one\nline two\ttabbed", object.get("notes").?.string);
2161}
2162
2163test "the timestamps keep their fraction rather than being truncated to a millisecond" {
2164 // `timeIntervalSince1970 * 1000` is a fractional double. Rounding it would be
2165 // a quiet change to a value pages use to order and place events.
2166 var event = sample_event;
2167 event.start_ms = 1700000000123.456;
2168 event.end_ms = 1700003600987.5;
2169
2170 const json = try shapeEvents(testing.allocator, &.{event});
2171 defer testing.allocator.free(json);
2172
2173 try testing.expect(std.mem.indexOf(u8, json, "\"startDate\":1700000000123.456") != null);
2174 try testing.expect(std.mem.indexOf(u8, json, "\"endDate\":1700003600987.5") != null);
2175}
2176
2177test "a non-finite timestamp is refused, not printed" {
2178 // `inf` and `nan` are not JSON. Printing either would produce a syntax error
2179 // inside the reply script with nothing to point at; `JSONSerialization`
2180 // refuses them too. Both fields are checked, because one unguarded call site
2181 // is all it takes.
2182 for ([_]f64{ std.math.inf(f64), -std.math.inf(f64), std.math.nan(f64) }) |bad| {
2183 var start_bad = sample_event;
2184 start_bad.start_ms = bad;
2185 try testing.expectError(
2186 bridge_error.BridgeError.InvalidParameter,
2187 shapeEvents(testing.allocator, &.{start_bad}),
2188 );
2189
2190 var end_bad = sample_event;
2191 end_bad.end_ms = bad;
2192 try testing.expectError(
2193 bridge_error.BridgeError.InvalidParameter,
2194 shapeEvents(testing.allocator, &.{end_bad}),
2195 );
2196 }
2197}
2198
2199test "the created event's reply is a bare JSON string" {
2200 const json = try shapeCreatedId(testing.allocator, "E-1234-ABCD");
2201 defer testing.allocator.free(json);
2202 try testing.expectEqualStrings("\"E-1234-ABCD\"", json);
2203}
2204
2205test "a nil identifier is bare null, not an empty string and not a rejection" {
2206 // Swift's `resolveCallback(id, result: event.eventIdentifier)` on a nil
2207 // implicitly-unwrapped optional renders `null` under `.fragmentsAllowed`.
2208 // Rejecting would tell the page the event was not created when it was;
2209 // `""` is swallowed by `payload || {}` just the same *and* is false about the
2210 // identifier. See the module comment.
2211 const json = try shapeCreatedId(testing.allocator, null);
2212 defer testing.allocator.free(json);
2213 try testing.expectEqualStrings("null", json);
2214}
2215
2216test "an identifier is escaped like any other user-visible string" {
2217 const json = try shapeCreatedId(testing.allocator, "E\"1\\2");
2218 defer testing.allocator.free(json);
2219 try testing.expectEqualStrings("\"E\\\"1\\\\2\"", json);
2220}
2221
2222test "the delete reply is bare true, which is what the page resolves" {
2223 // `craft.d.ts:295` declares `Promise<void>`; `resolveCallback(id, result:
2224 // true)` puts `true` on the wire. `payload || {}` passes `true` through
2225 // unchanged, so this is the one reply here the coercion does not touch.
2226 try testing.expectEqualStrings("true", true_fragment);
2227}
2228
2229test "milliseconds become seconds exactly once" {
2230 // Swift divides at the NSDate (3297-3298, 3332, 3335). Doing it twice, or
2231 // not at all, moves every event by a factor of a thousand — 1970 or the year
2232 // 55000, both of which look like data rather than a bug.
2233 try testing.expectEqual(@as(f64, 1700000000), secondsFromMilliseconds(1700000000000));
2234 try testing.expectEqual(@as(f64, 0), secondsFromMilliseconds(0));
2235 try testing.expectEqual(@as(f64, -1.5), secondsFromMilliseconds(-1500));
2236 try testing.expectEqual(@as(f64, 1700000000.1234), secondsFromMilliseconds(1700000000123.4));
2237}
2238
2239// ---------------------------------------------------------------------------
2240// Transcribed constants
2241// ---------------------------------------------------------------------------
2242
2243test "the framework constants are the ones the headers give" {
2244 // Transcribed from headers rather than read from the runtime, so a test is
2245 // the only thing holding them. A wrong entity type requests reminders and
2246 // reports it as calendar access; a wrong span edits or deletes every
2247 // occurrence of a recurring event; a wrong calendar unit changes the default
2248 // range from a month to a day or a year.
2249 try testing.expectEqual(@as(c_ulong, 0), ek_entity_type_event);
2250 try testing.expectEqual(@as(c_long, 0), ek_span_this_event);
2251 try testing.expectEqual(@as(c_ulong, 8), ns_calendar_unit_month);
2252 try testing.expectEqual(@as(c_ulong, 0), ns_calendar_options_none);
2253 try testing.expectEqual(@as(c_ulong, 4), ns_utf8_string_encoding);
2254 try testing.expectEqualStrings("NSCalendarsUsageDescription", key_calendars_usage);
2255}
2256
2257test "the deprecated access request is the one this file makes" {
2258 // Swift 3291/3320 call `requestAccessToEntityType:` with no availability
2259 // fork, and `packages/ios/src/index.ts:190` writes only
2260 // NSCalendarsUsageDescription. The iOS 17 replacement needs
2261 // NSCalendarsFullAccessUsageDescription, which nothing in this repo writes,
2262 // so calling it would terminate the app.
2263 //
2264 // The needle is the *call* form rather than the bare API name, so the prose
2265 // in this file cannot match itself and the assertion keeps meaning
2266 // something — which is why the needle is not spelled out in any comment,
2267 // including this one.
2268 const source = @embedFile("bridge_mobile_calendar.zig");
2269 try testing.expect(std.mem.indexOf(u8, source, "selector(\"requestFullAccess") == null);
2270 // Non-vacuity: the same scan finds the call that is present, so a needle
2271 // that stopped matching anything could not pass unnoticed.
2272 try testing.expect(std.mem.indexOf(u8, source, "selector(\"requestAccessToEntityType") != null);
2273}
2274
2275// ---------------------------------------------------------------------------
2276// The side table
2277// ---------------------------------------------------------------------------
2278
2279test "a second completion for the same slot finds nothing to answer" {
2280 // Clearing on read is what makes a duplicate fire a no-op rather than a
2281 // second reply to a promise that has already settled — and, for a pending
2282 // create, rather than a double free of its strings.
2283 resetPendingForTesting();
2284 defer resetPendingForTesting();
2285
2286 const ticket = fakeTicket(3, 7);
2287 publishPendingCall(ticket, fakeSels(), null, .{ .fetch = .{} });
2288
2289 const first = takePendingCall(3);
2290 try testing.expect(first != null);
2291 try testing.expectEqual(@as(u32, 7), first.?.ticket.generation);
2292
2293 try testing.expect(takePendingCall(3) == null);
2294}
2295
2296test "two calls in flight keep separate entries" {
2297 // The reason this is a per-slot table rather than contactpicker's single
2298 // `pending`: nothing here is modal, so two getCalendarEvents at once is an
2299 // ordinary page, and one slot would refuse the second for no reason.
2300 resetPendingForTesting();
2301 defer resetPendingForTesting();
2302
2303 publishPendingCall(fakeTicket(0, 1), fakeSels(), null, .{ .fetch = .{ .start_ms = 1 } });
2304 publishPendingCall(fakeTicket(1, 1), fakeSels(), null, .{ .fetch = .{ .start_ms = 2 } });
2305
2306 const second = takePendingCall(1) orelse return error.EntryMissing;
2307 try testing.expectEqual(@as(f64, 2), second.request.fetch.start_ms.?);
2308
2309 // Taking one must not disturb the other.
2310 const first = takePendingCall(0) orelse return error.EntryMissing;
2311 try testing.expectEqual(@as(f64, 1), first.request.fetch.start_ms.?);
2312}
2313
2314test "a pending create owns its strings and frees them all" {
2315 // The strings outlive the dispatch frame, so they are allocated from the
2316 // allocator the completion frees with. Running it under the testing
2317 // allocator is what proves the ownership is complete rather than asserted.
2318 const event = try parseNewEvent(
2319 testing.allocator,
2320 "{\"event\":{\"title\":\"Standup\",\"location\":\"Room 2\",\"notes\":\"bring laptop\"}}",
2321 );
2322
2323 resetPendingForTesting();
2324 defer resetPendingForTesting();
2325
2326 publishPendingCall(fakeTicket(2, 4), fakeSels(), null, .{ .create = event });
2327
2328 const call = takePendingCall(2) orelse return error.EntryMissing;
2329 try testing.expectEqualStrings("Standup", call.request.create.title);
2330 call.request.deinit(testing.allocator);
2331}
2332
2333// ---------------------------------------------------------------------------
2334// The gates, exercised for real
2335// ---------------------------------------------------------------------------
2336
2337test "a process without the calendar usage description is refused before EventKit is touched" {
2338 // The test runner is exactly the process the gate exists for: no bundle
2339 // Info.plist, so no NSCalendarsUsageDescription. On a device that is an app
2340 // built with `enableCalendar: false`, and requesting access there would raise
2341 // NSInternalInconsistencyException — an uncatchable SIGABRT — which is why
2342 // this runs before the store is ever created.
2343 //
2344 // Off Darwin the same call is refused one step earlier, at the platform
2345 // guard. Either way the answer is a rejection and never silence, and neither
2346 // path reaches EventKit.
2347 var bridge = CalendarBridge.init(testing.allocator);
2348 defer bridge.deinit();
2349
2350 const expected = if (is_darwin)
2351 bridge_error.BridgeError.PermissionDenied
2352 else
2353 error.UnsupportedPlatform;
2354
2355 try testing.expectError(expected, bridge.handleMessage(A.get_calendar_events, "{}"));
2356 try testing.expectError(
2357 expected,
2358 bridge.handleMessage(A.create_calendar_event, "{\"event\":{\"title\":\"x\"}}"),
2359 );
2360 // Delete is gated too. It requests no access, so for that one action the key
2361 // is only evidence of `config.enableCalendar` — but the flag guards all three
2362 // Swift cases, so it guards all three here.
2363 try testing.expectError(
2364 expected,
2365 bridge.handleMessage(A.delete_calendar_event, "{\"eventId\":\"E1\"}"),
2366 );
2367}
2368
2369test "a malformed payload is refused before any platform or permission gate" {
2370 // Parsing first is what makes a bad body report the same cause on every
2371 // platform, and report it before a framework is consulted. These three
2372 // assertions hold identically on a host with no EventKit and on a device.
2373 var bridge = CalendarBridge.init(testing.allocator);
2374 defer bridge.deinit();
2375
2376 try testing.expectError(
2377 bridge_error.BridgeError.InvalidJSON,
2378 bridge.handleMessage(A.get_calendar_events, "not json"),
2379 );
2380 try testing.expectError(
2381 bridge_error.BridgeError.MissingData,
2382 bridge.handleMessage(A.create_calendar_event, "{}"),
2383 );
2384 try testing.expectError(
2385 bridge_error.BridgeError.MissingData,
2386 bridge.handleMessage(A.delete_calendar_event, "{}"),
2387 );
2388 try testing.expectError(
2389 bridge_error.BridgeError.InvalidParameter,
2390 bridge.handleMessage(A.get_calendar_events, "{\"startDate\":\"2024-01-01\"}"),
2391 );
2392}
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}
packages/zig/src/bridge_mobile_speech.zigadded+810-0
Changes to packages/zig/src/bridge_mobile_speech.zig
@@ -0,0 +1,810 @@
1//! The Speech + AVFoundation actions of the `mobile` namespace —
2//! `startListening` and `stopListening` — **researched in full and
3//! deliberately not served**. Both fall through to the working Swift shim.
4//!
5//! This is the outcome rule 6 exists for, and it is the cheapest one in the
6//! migration: the shim answers both actions exactly as the app answers them
7//! today, and — unlike `scheduleNotification`, `registerBackgroundTask` and
8//! `sendToWatch` — **there is no promise to strand**, because neither action
9//! has a `callbackId` and neither ever replies. Falling through costs the page
10//! nothing at all. What is written below is the part a host can honestly
11//! finish and pin: the event vocabulary, the four error messages, and the two
12//! detail shapers. What is missing is named, not glossed.
13//!
14//! ## The contract, read rather than assumed
15//!
16//! **Payload: none, on both actions.** The injected JS is the whole surface
17//! (`CraftApp.swift:1562-1568`):
18//!
19//! ```js
20//! startListening: function() {
21//! window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
22//! },
23//! stopListening: function() {
24//! window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
25//! },
26//! ```
27//!
28//! No fields, no `callbackId`, no return value. `ios_dispatch.payloadOf` would
29//! hand a handler `"{}"`. There is no second page surface: the nested mobile
30//! contract block (`:2297-2427`) has no speech methods, and `craft-bridge.js`
31//! has no `mobile` namespace at all — its `window.craft.speech` is desktop TTS
32//! over `AVSpeechSynthesizer`, namespace `t = "speech"`, unrelated. So is
33//! `bridge_speech_recognition.zig`, which is the desktop `speechRecognition`
34//! namespace with actions `isAvailable`/`start`/`stop`; it must not be
35//! confused with this one.
36//!
37//! **Reply: none, on any path.** The dispatcher (`CraftApp.swift:533-536`) is
38//!
39//! ```swift
40//! case "startListening":
41//! if config.enableSpeechRecognition { startSpeechRecognition() }
42//! case "stopListening":
43//! stopSpeechRecognition()
44//! ```
45//!
46//! and neither `startSpeechRecognition` (`:2486-2494`), `beginRecording`
47//! (`:2496-2548`) nor `stopSpeechRecognition` (`:2550-2559`) touches
48//! `resolveCallback`, `rejectCallback` or `resolveCallbackJSON` on any line.
49//! `craft.d.ts:188` and `:193` both declare `(): void`, and
50//! `test-bridges.html:785-795` awaits nothing. If this module ever serves
51//! these, the declaration is `.reply = .none` — the same reading
52//! `bridge_mobile_haptics.zig` made for `haptic`, and for the same reason.
53//!
54//! A denial does not reply either; it **emits**. Nor does the flag-off case
55//! emit anything at all: `if config.enableSpeechRecognition { … }` has no
56//! `else`, and `stopListening` is ungated and always runs.
57//!
58//! **Everything is events.** Four names, and the detail of each:
59//!
60//! | event | detail | when |
61//! |---|---|---|
62//! | `craftSpeechError` | `{"error":"Not authorized"}` | auth status is not `.authorized` (`:2489`) |
63//! | `craftSpeechError` | `{"error":"Audio session failed"}` | `setCategory`/`setActive` threw (`:2507`) |
64//! | `craftSpeechError` | `{"error":"Speech recognizer unavailable"}` | request nil, recognizer nil, or not `isAvailable` (`:2515`) |
65//! | `craftSpeechError` | `{"error":"Audio engine failed"}` | `audioEngine.start()` threw (`:2546`) |
66//! | `craftSpeechStart` | `{}` | after `audioEngine.start()` succeeded (`:2543`) |
67//! | `craftSpeechResult` | `{"transcript": String, "isFinal": Bool}` | every partial and every final result (`:2525-2528`) |
68//! | `craftSpeechEnd` | `{}` | every `stopSpeechRecognition()` (`:2557`) |
69//!
70//! The result detail is **exactly two keys**. No `confidence`, no
71//! `alternatives`, no `segments`. Three independent consumers agree —
72//! `test-bridges.html:384-387` reads `e.detail.transcript` and
73//! `e.detail.isFinal`, `packages/ios/README.md:167-176` documents the same
74//! pair, and Android emits the same four names with the same keys
75//! (`CraftBridge.kt.template:1344-1383`). Unlike motion, there is no
76//! doc/implementation mismatch here to report.
77//!
78//! `sendToWeb` (`:4835-4846`) builds
79//! `window.dispatchEvent(new CustomEvent('<name>', {detail: <json>}));` — the
80//! same form `ios_events.formatEvent` builds, so the detail bytes below are
81//! the bytes that reach `e.detail` on either path.
82//!
83//! ## Why nothing is served
84//!
85//! Four blockers. Only the last three are load-bearing; the first is one line
86//! of data that would unblock nothing on its own.
87//!
88//! **1. `craftSpeechStart` cannot be spelled.** `ios_events.Event` carries
89//! `.speech_result`, `.speech_error` and `.speech_end`, and no
90//! `.speech_start`. Serving `startListening` without it would drop the one
91//! event that drives the page's state — `test-bridges.html:380-383` paints
92//! "Listening..." from it and from nothing else — so a working session would
93//! look to the page exactly like a session that never started. Adding the
94//! enum member and its `switch` arm is data rather than mechanism, and the
95//! existing test `"every event name is one an iOS page actually subscribes
96//! to"` already accepts the shape (`startsWith "craft"`, no colon). It is not
97//! left undone because a second file is off limits — landing this module
98//! already edits `ios_dispatch.zig`, `build.zig` and
99//! `test/ios_conformance_test.zig`. It is left undone because adding it alone
100//! serves nothing: blocker 2 stops `startListening` by itself, and an `Event`
101//! member no code emits is vocabulary for an event that never fires. So it is
102//! precondition 1 below rather than a reason of its own — and a test trips
103//! when it lands.
104//!
105//! **2. The realtime tap has no precedent in this tree.**
106//! `-[AVAudioNode installTapOnBus:bufferSize:format:block:]` runs its block on
107//! the audio I/O thread, which is not a queue any existing module touches.
108//! `bridge_mobile_motion.zig`'s handler is an `NSOperationQueue.mainQueue`
109//! callback and is **not** a precedent for it. Swift's
110//! `self?.recognitionRequest?.append(buffer)` gets a free ARC retain for the
111//! duration of the call; Zig has none, so `stopListening` releasing the
112//! request while a tap call is in flight is a use-after-free on a realtime
113//! thread. It is solvable — an atomic pointer, never a mutex (priority
114//! inversion), plus stop-engine, remove-tap, clear-pointer, never-release
115//! ordering — but solving it is building a new foundation, and this round was
116//! asked to apply proven ones and to say so instead.
117//!
118//! **3. The guard the tap needs is asserted, not read.** With no active input
119//! route, `-outputFormatForBus:0` yields a format whose `sampleRate` and
120//! `channelCount` are 0, and `installTapOnBus:` then raises an
121//! `NSException` — which Zig cannot catch, so the process aborts. Swift has
122//! the identical crash at `:2535-2538`. The honest port pre-checks the format
123//! and refuses with `craftSpeechError`; but that behaviour comes from API
124//! knowledge, nothing in this repo documents it, and there is no simulator
125//! here to verify it. Shipping an unverified guard against an unreproducible
126//! abort is the one place rule 5 and rule 1 meet.
127//!
128//! **4. Nothing here can test the audio path.** Every host test that could be
129//! written for a served version would exercise selector interning and block
130//! flags, and nothing of the recognizer, the session, the engine or the tap.
131//! That is a served action whose entire risk surface is untested.
132//!
133//! The gate is *not* among the blockers, because it is solved:
134//! `config.enableSpeechRecognition` defaults to `false`
135//! (`CraftApp.swift:189`, `packages/ios/src/index.ts:117`) and reaches Zig
136//! through nothing, but `packages/ios/src/index.ts:183` writes
137//! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else,
138//! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` already
139//! reads. (`NSMicrophoneUsageDescription` at `:184` is
140//! `enableSpeechRecognition || enableAudioRecording` and is **not** an exact
141//! proxy; it must not be used as the gate.) Here the proxy is mandatory
142//! rather than an improvement: without those keys in Info.plist iOS
143//! *terminates the process* on the authorization request or on mic access.
144//!
145//! ## Why falling through, and not `.unavailable`
146//!
147//! `.unavailable` makes `ios_dispatch` route the action into this module and
148//! refuse it. For an action with a pending promise that is a nameable failure
149//! and an improvement on silence. Here there is no pending promise on any
150//! page surface, so the refusal would surface only as a console log — while
151//! taking away a shim path that works completely. That is strictly worse, and
152//! it is why `A` below is empty rather than carrying two `.unavailable` rows.
153//!
154//! Serving only `stopListening` is worse still, and is ruled out for the
155//! reason `bridge_mobile_motion.zig` gives: a session started through the shim
156//! would meet a Zig stop, which would stop Zig's own null engine and emit
157//! `craftSpeechEnd` while Swift's recognizer kept pushing `craftSpeechResult`.
158//! Fabricated success at the event layer. Either both or neither; neither.
159//!
160//! ## What is finished here
161//!
162//! The two shapers and the four messages — the half of the port that decides
163//! page-visible bytes and that a host can pin. This is the precedent
164//! `bridge_mobile_haptics.zig` set with `triggerHapticChecked`: the finished
165//! half of a blocked action lives next to the admission, tested, so that
166//! unblocking it is a small deliberate edit rather than a rewrite.
167//!
168//! `shapeResultDetail` is worth more than it looks. A transcript is arbitrary
169//! spoken text, the detail is inlined into JavaScript source as a literal, and
170//! `ios_events.zig`'s own module comment records what happens when a value
171//! like that is concatenated with hand-rolled escaping: `bridge_iap.zig:362`
172//! "works until a value contains a quote". Swift is safe here only because
173//! `JSONSerialization` escapes for it. Any Zig port must, and this one does.
174//!
175//! ## What a future round still owes
176//!
177//! Five preconditions, all five, before either action moves into `A`:
178//!
179//! 1. `.speech_start => "craftSpeechStart"` in `ios_events.Event`.
180//! 2. Refuse unless Info.plist carries `NSSpeechRecognitionUsageDescription`
181//! (`bridge_mobile_motion.zig:infoPlistHas` is the shape).
182//! 3. Validate `-sampleRate` and `-channelCount` before `installTapOnBus:`,
183//! and emit `craftSpeechError` rather than installing on a zero format.
184//! 4. Never release the recognition request while a tap call may be in
185//! flight — atomically clear the pointer after `removeTapOnBus:`, and
186//! prefer leaking one request per session over racing the audio thread.
187//! 5. Hop to the main queue with `dispatch_async_f(&_dispatch_main_q, …)`
188//! before touching `AVAudioEngine` from the recognition task handler; it
189//! fires on an internal Speech queue. (`ios_events.emit` needs no hop —
190//! it hops itself.)
191//!
192//! And two decisions that are decisions, not facts, to be taken out loud:
193//!
194//! - **The error-emission divergence.** On a recognition error Swift emits
195//! **no** `craftSpeechError` — `:2530-2532` only calls
196//! `stopSpeechRecognition()`, so the page gets `craftSpeechEnd` and the
197//! `NSError` is discarded entirely. Android *does* emit one
198//! (`CraftBridge.kt.template:1367`). Matching Swift makes a simulator
199//! failure look exactly like a stream that never fires; matching Android
200//! tells the truth but deviates from the emitter contract this migration
201//! preserves. `err_recognition_failed` below is deliberately absent: there
202//! is no Swift string to copy, so inventing one here would prejudge it.
203//! - **`beginRecording` never stops the engine or removes the old tap**
204//! (`:2497-2500` cancels only the task). A second `startListening` while
205//! running installs a second tap on bus 0, which raises on iOS.
206//! Reproducing that bug is not required; stopping first, as
207//! `bridge_mobile_motion.zig` chose to, is the defensible port.
208//!
209//! Also worth carrying: both `:2544` and `:2558` fire `triggerHaptic("light")`
210//! directly, bypassing the `config.enableHaptics` gate the dispatcher applies
211//! to `case "haptic"` at `:537`. Swift itself ignores the flag on this path,
212//! so firing it from a served port would be faithful rather than a
213//! divergence — but `triggerHapticChecked` in `bridge_mobile_haptics.zig` is
214//! not `pub`, so reuse needs a visibility change there.
215//!
216//! ## Nothing in this file touches Objective-C
217//!
218//! No `objc_getClass`, no `sel_registerName`, no block, no delegate, no
219//! `ios_async` ticket — there is no reply to correlate and no framework call
220//! to make. So there is nothing to Darwin-gate: the module compiles and its
221//! tests run identically on Linux and macOS, which is the one upside of
222//! serving nothing.
223
224const std = ;
225const capabilities = ;
226const bridge_error = ;
227const ios_events = ;
228
229/// **Empty on purpose.** No action name here means `ios_dispatch` never routes
230/// `startListening` or `stopListening` into this module, so both reach the
231/// Swift shim that serves them correctly today. See the module comment for the
232/// four blockers and the five preconditions.
233///
234/// `declared_count` is not decoration. `test/ios_conformance_test.zig` scans
235/// this block line by line from `pub const A = struct {` to the first bare
236/// `};`, and `zig fmt` collapses an empty struct onto one line — after which
237/// the scan would never find its terminator and would collect every quoted
238/// string in this file as an action name. A non-string member keeps the block
239/// two-line-safe under `zig fmt --check`, which CI runs.
240pub const A = struct {
241 /// How many actions this module claims. Zero, deliberately.
242 pub const declared_count: usize = 0;
243};
244
245/// Empty, and specifically not two `.unavailable` rows.
246///
247/// `.unavailable` is for an action that dispatches and refuses — honest when a
248/// promise would otherwise hang, wrong here. Neither of these actions has a
249/// promise on any page surface, so a refusal would show up only in the
250/// console while removing a shim path that works end to end. Declaring
251/// nothing is what lets the shim keep answering.
252pub const capability_actions = [_]capabilities.ActionDecl{};
253
254/// The two spec actions this module researched and left with the shim.
255///
256/// Kept as data so the tests can prove both fall through rather than asserting
257/// it in prose, and so a future round has the exact spellings in one place.
258/// These names deliberately live outside the `A` block: inside it, the
259/// conformance scan would read them as served and the ratchet would drop for
260/// actions nothing here answers.
261pub const unserved_actions = [_][]const u8{ "startListening", "stopListening" };
262
263pub const SpeechBridge = struct {
264 /// Held but never used: `ios_dispatch` constructs every mobile bridge with
265 /// `init(allocator)` and the shape is the interface. Nothing here
266 /// allocates, because nothing here answers.
267 allocator: std.mem.Allocator,
268
269 const Self = @This();
270
271 pub fn init(allocator: std.mem.Allocator) Self {
272 return .{ .allocator = allocator };
273 }
274
275 pub fn deinit(_: *Self) void {}
276
277 /// Always `UnknownAction` — "not mine, ask the next" — which is what makes
278 /// the fall-through work.
279 ///
280 /// The payload is deliberately never parsed. `ios_dispatch.route` treats
281 /// any error other than `UnknownAction` as final and stops the chain, so a
282 /// module that parsed first and answered `InvalidJSON` on a malformed
283 /// payload would strand an action it does not even serve. That is the
284 /// failure this signature is shaped to make impossible, and a test below
285 /// pins it with payloads that no parser would accept.
286 pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void {
287 _ = action;
288 return bridge_error.BridgeError.UnknownAction;
289 }
290};
291
292// =============================================================================
293// The event vocabulary, as far as it can be spelled today.
294// =============================================================================
295
296/// `craftSpeechResult` — the transcript stream.
297pub const event_result: ios_events.Event = .speech_result;
298
299/// `craftSpeechError` — every refusal, on every path.
300pub const event_error: ios_events.Event = .speech_error;
301
302/// `craftSpeechEnd` — every `stopSpeechRecognition()`, including one that
303/// stops nothing.
304pub const event_end: ios_events.Event = .speech_end;
305
306/// `craftSpeechStart`, which `ios_events.Event` cannot currently spell.
307///
308/// A plain string, not an `Event`, because that is the whole point: there is
309/// no member to name. Pinned here so the eventual enum arm is copied from the
310/// contract rather than retyped from memory.
311pub const event_start_name = "craftSpeechStart";
312
313/// The `data: [:]` of `craftSpeechStart` and `craftSpeechEnd` — an empty
314/// object, not an empty string and not `null`. `ios_events.formatEvent`
315/// inlines the detail as a literal, so `e.detail` must still be an object the
316/// page can index into.
317pub const empty_detail = "{}";
318
319// -----------------------------------------------------------------------------
320// The four `craftSpeechError` messages, verbatim from the Swift.
321//
322// Verbatim matters: `test-bridges.html:391-394` paints `e.detail.error` into
323// the page for a human to read, and `packages/ios/README.md:173-175` documents
324// the key as the thing to log. Nothing downstream matches on the text, which
325// is exactly why a paraphrase would change what a user reads without changing
326// anything a test could see.
327// -----------------------------------------------------------------------------
328
329/// `CraftApp.swift:2489` — the authorization status was not `.authorized`.
330/// A user declining is this, not a native call failing.
331pub const err_not_authorized = "Not authorized";
332
333/// `:2507` — `setCategory:mode:options:error:` or
334/// `setActive:withOptions:error:` failed.
335pub const err_audio_session = "Audio session failed";
336
337/// `:2515` — the request is nil, the recognizer is nil (the
338/// `enableSpeechRecognition` flag was off, `:439-440`), or `-isAvailable` is
339/// NO. A simulator commonly answers NO here.
340pub const err_recognizer_unavailable = "Speech recognizer unavailable";
341
342/// `:2546` — `-startAndReturnError:` returned NO.
343pub const err_audio_engine = "Audio engine failed";
344
345/// Every message the port may emit, in Swift's source order. A future round
346/// adding a fifth must add it to Swift's contract first, or document the
347/// divergence — see the module comment on the recognition-error case.
348pub const error_messages = [_][]const u8{
349 err_not_authorized,
350 err_audio_session,
351 err_recognizer_unavailable,
352 err_audio_engine,
353};
354
355// =============================================================================
356// The detail shapers. Pure, and the bytes are the contract.
357// =============================================================================
358
359/// The `detail` of one `craftSpeechError`: `{"error":"<message>"}`.
360///
361/// Escaped even though all four current messages are plain ASCII, because the
362/// escaping is a property of the position — a JSON string inlined into
363/// JavaScript source — and not of today's inputs. A fifth message quoting a
364/// framework error would otherwise be the bug.
365///
366/// Caller owns the returned slice.
367pub fn shapeErrorDetail(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
368 var out: std.ArrayListUnmanaged(u8) = .empty;
369 errdefer out.deinit(allocator);
370
371 try out.appendSlice(allocator, "{\"error\":\"");
372 try bridge_error.appendJsonEscaped(allocator, &out, message);
373 try out.appendSlice(allocator, "\"}");
374
375 return out.toOwnedSlice(allocator);
376}
377
378/// The `detail` of one `craftSpeechResult`:
379/// `{"transcript":"<text>","isFinal":<bool>}`.
380///
381/// Two keys, Swift's two (`CraftApp.swift:2525-2528`), and no third. Swift
382/// builds a `Dictionary`, so its own key order is arbitrary and no page can
383/// depend on it; one order is fixed here so the bytes are testable.
384///
385/// `transcript` is escaped because it is arbitrary spoken text landing in a
386/// JavaScript source position. An unescaped `"` would not corrupt one field —
387/// it would end the object early and make the whole `dispatchEvent` call a
388/// syntax error, so the page would see the stream stop rather than see a bad
389/// transcript. That is the failure `ios_events.zig` records against
390/// `bridge_iap.zig:362`, and the reason Swift is safe here is only that
391/// `JSONSerialization` escapes on its behalf.
392///
393/// `isFinal` is a real JSON boolean, never the string `"true"`: the page
394/// interpolates it into a template literal (`test-bridges.html:386`), where a
395/// quoted `"false"` would render as `false` and read as correct while being a
396/// truthy value to any code that branches on it.
397///
398/// An empty transcript still emits the key. Swift always writes it — a first
399/// partial result can legitimately be empty — and omitting it would make
400/// `e.detail.transcript` `undefined`, which a page cannot tell from a broken
401/// bridge.
402///
403/// Caller owns the returned slice.
404pub fn shapeResultDetail(
405 allocator: std.mem.Allocator,
406 transcript: []const u8,
407 is_final: bool,
408) ![]u8 {
409 var out: std.ArrayListUnmanaged(u8) = .empty;
410 errdefer out.deinit(allocator);
411
412 try out.appendSlice(allocator, "{\"transcript\":\"");
413 try bridge_error.appendJsonEscaped(allocator, &out, transcript);
414 try out.appendSlice(allocator, "\",\"isFinal\":");
415 try out.appendSlice(allocator, if (is_final) "true" else "false");
416 try out.append(allocator, '}');
417
418 return out.toOwnedSlice(allocator);
419}
420
421// =============================================================================
422// Tests.
423//
424// Two things are under test. First, that this module really does serve
425// nothing — declaring nothing is only half of it, since a module that
426// answered anything other than `UnknownAction` would break the fall-through
427// even with an empty table. Second, the finished half: the event names and the
428// two detail shapers, whose bytes are what a page reads.
429//
430// Nothing here is Darwin-gated, because nothing in this file touches
431// Objective-C.
432// =============================================================================
433
434const testing = std.testing;
435
436test "this module declares no actions, so both fall through to the shim" {
437 // Table -> dispatch, with the table empty. The shim serves both actions
438 // correctly today and neither has a promise to strand, so an empty table
439 // is the whole feature: `ios_dispatch.route` never enters this module and
440 // `handOffToHost` answers.
441 try testing.expectEqual(@as(usize, 0), capability_actions.len);
442 try testing.expectEqual(@as(usize, 0), A.declared_count);
443}
444
445test "the two unserved actions are the spec's, spelled exactly" {
446 // The spelling a future round will move into `A`. Pinned here so the move
447 // is a copy rather than a retype — `startlistening` would route nowhere and
448 // look exactly like this module still not serving it.
449 try testing.expectEqual(@as(usize, 2), unserved_actions.len);
450 try testing.expectEqualStrings("startListening", unserved_actions[0]);
451 try testing.expectEqualStrings("stopListening", unserved_actions[1]);
452}
453
454test "neither unserved action is in the action table" {
455 // The invariant that keeps the ratchet honest. An action listed in `A`
456 // counts as migrated to `test/ios_conformance_test.zig` whether or not
457 // anything answers it, so a name leaking from `unserved_actions` into the
458 // table would lower the ratchet for work nobody did.
459 for (unserved_actions) |name| {
460 for (capability_actions) |decl| {
461 try testing.expect(!std.mem.eql(u8, decl.name, name));
462 }
463 }
464}
465
466test "handleMessage refuses everything, including the actions it researched" {
467 // Dispatch -> table, with both empty. `UnknownAction` is the only error
468 // `ios_dispatch.route` reads as "not mine, ask the next"; any other error
469 // is treated as final and would stop the chain before the shim.
470 var bridge = SpeechBridge.init(testing.allocator);
471 defer bridge.deinit();
472
473 const E = bridge_error.BridgeError;
474
475 for (unserved_actions) |name| {
476 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}"));
477 }
478
479 // Casing, near misses, and the neighbouring namespaces' spellings — the
480 // desktop `speechRecognition` actions in particular, which are a different
481 // namespace and must never be answered from here.
482 for ([_][]const u8{
483 "startlistening",
484 "StartListening",
485 "startListen",
486 "listen",
487 "start",
488 "stop",
489 "isAvailable",
490 "speak",
491 "startMotionUpdates",
492 "getDeviceInfo",
493 "",
494 }) |name| {
495 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}"));
496 }
497}
498
499test "no payload can turn the fall-through into a final error" {
500 // The subtle way a serve-nothing module breaks the thing it is protecting.
501 // `ios_dispatch.route` stops the chain on any error but `UnknownAction`, so
502 // a module that parsed its payload before routing would answer
503 // `InvalidJSON` for a malformed one and strand an action it does not
504 // serve — the page would get a rejection where the shim would have worked.
505 var bridge = SpeechBridge.init(testing.allocator);
506 defer bridge.deinit();
507
508 const E = bridge_error.BridgeError;
509
510 for ([_][]const u8{
511 "{not json",
512 "",
513 "null",
514 "[1,2,3]",
515 "{\"transcript\":\"hello\"}",
516 "{\"callbackId\":\"cb_7\"}",
517 }) |payload| {
518 for (unserved_actions) |name| {
519 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, payload));
520 }
521 }
522}
523
524test "the three spellable events are the ones the page subscribes to" {
525 // `test-bridges.html:384`, `:388`, `:391`, and Swift's `sendToWeb` call
526 // sites. camelCase, no colon — the iOS vocabulary, which is why
527 // `ios_events.Event` exists separately from `capabilities.Channel`.
528 try testing.expectEqualStrings("craftSpeechResult", event_result.eventName());
529 try testing.expectEqualStrings("craftSpeechError", event_error.eventName());
530 try testing.expectEqualStrings("craftSpeechEnd", event_end.eventName());
531
532 // And no desktop channel can spell any of them, so emitting through
533 // `capabilities.Channel` would fire events with no subscriber.
534 for (std.enums.values(capabilities.Channel)) |channel| {
535 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_result.eventName()));
536 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_error.eventName()));
537 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_end.eventName()));
538 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_start_name));
539 }
540 // Non-vacuity: the loop above proves nothing over an empty enum.
541 try testing.expect(std.enums.values(capabilities.Channel).len >= 40);
542}
543
544test "craftSpeechStart is still unspellable, which is blocker one" {
545 // A tripwire, not a preference. This module's first blocker is that
546 // `ios_events.Event` has no `.speech_start`, and the moment somebody adds
547 // it precondition 1 is met — which should be noticed here, in the file
548 // that documents it, rather than in a later round rediscovering the
549 // research. Blockers 2-4 still stand on their own, so this trip is a
550 // prompt to re-read them, never a clearance to serve.
551 if (@hasField(ios_events.Event, "speech_start")) {
552 std.debug.print(
553 "ios_events.Event now has .speech_start, so blocker 1 in " ++
554 "bridge_mobile_speech.zig is lifted.\n" ++
555 " Emit it through the enum, delete event_start_name, and work " ++
556 "through preconditions 2-5 before moving either action into `A`.\n",
557 .{},
558 );
559 return error.SpeechStartBlockerLifted;
560 }
561
562 // Until then the name exists only as a string, and it is the right one.
563 try testing.expectEqualStrings("craftSpeechStart", event_start_name);
564}
565
566test "start and end carry an empty object, not an empty string" {
567 // `data: [:]` through `JSONSerialization` is `{}`. `ios_events.formatEvent`
568 // inlines the detail as a literal, so `""` would produce
569 // `{detail:}` — a syntax error that takes the whole event with it — and
570 // `"null"` would make `e.detail` null where the page expects an object.
571 try testing.expectEqualStrings("{}", empty_detail);
572
573 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, empty_detail, .{});
574 defer parsed.deinit();
575 try testing.expectEqual(@as(usize, 0), parsed.value.object.count());
576}
577
578// -----------------------------------------------------------------------------
579// craftSpeechError
580// -----------------------------------------------------------------------------
581
582test "the four error messages are Swift's, character for character" {
583 // These strings are shown to a human (`test-bridges.html:392-393`) and
584 // documented in README.md. A paraphrase changes what a user reads without
585 // changing anything else a test can see.
586 try testing.expectEqualStrings("Not authorized", err_not_authorized);
587 try testing.expectEqualStrings("Audio session failed", err_audio_session);
588 try testing.expectEqualStrings("Speech recognizer unavailable", err_recognizer_unavailable);
589 try testing.expectEqualStrings("Audio engine failed", err_audio_engine);
590 try testing.expectEqual(@as(usize, 4), error_messages.len);
591}
592
593test "an error detail is the single key the page reads" {
594 // `e.detail.error` (`test-bridges.html:392`) and nothing else. A `code` or
595 // `message` key alongside it would be a surface no consumer knows about.
596 const json = try shapeErrorDetail(testing.allocator, err_not_authorized);
597 defer testing.allocator.free(json);
598
599 try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", json);
600
601 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
602 defer parsed.deinit();
603 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
604 try testing.expectEqualStrings("Not authorized", parsed.value.object.get("error").?.string);
605}
606
607test "every one of the four messages shapes and parses back" {
608 for (error_messages) |message| {
609 const json = try shapeErrorDetail(testing.allocator, message);
610 defer testing.allocator.free(json);
611
612 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
613 defer parsed.deinit();
614 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
615 try testing.expectEqualStrings(message, parsed.value.object.get("error").?.string);
616 }
617}
618
619test "an error message carrying a quote does not end the object early" {
620 // No current message does. The escaping is a property of the position, not
621 // of today's inputs, and a future round quoting a framework error is
622 // exactly how the unescaped case arrives.
623 const json = try shapeErrorDetail(testing.allocator, "recognizer said \"no\"\\ever");
624 defer testing.allocator.free(json);
625
626 try testing.expectEqualStrings(
627 "{\"error\":\"recognizer said \\\"no\\\"\\\\ever\"}",
628 json,
629 );
630
631 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
632 defer parsed.deinit();
633 try testing.expectEqualStrings(
634 "recognizer said \"no\"\\ever",
635 parsed.value.object.get("error").?.string,
636 );
637}
638
639// -----------------------------------------------------------------------------
640// craftSpeechResult
641// -----------------------------------------------------------------------------
642
643test "a result detail is exactly transcript and isFinal" {
644 // `CraftApp.swift:2525-2528`, and the two keys `test-bridges.html:386`
645 // reads. These are the bytes `e.detail` receives either way, because
646 // `sendToWeb` and `ios_events.formatEvent` inline the detail identically.
647 const partial = try shapeResultDetail(testing.allocator, "hello world", false);
648 defer testing.allocator.free(partial);
649 try testing.expectEqualStrings(
650 "{\"transcript\":\"hello world\",\"isFinal\":false}",
651 partial,
652 );
653
654 const final = try shapeResultDetail(testing.allocator, "hello world", true);
655 defer testing.allocator.free(final);
656 try testing.expectEqualStrings(
657 "{\"transcript\":\"hello world\",\"isFinal\":true}",
658 final,
659 );
660
661 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, final, .{});
662 defer parsed.deinit();
663 try testing.expectEqual(@as(usize, 2), parsed.value.object.count());
664 try testing.expectEqualStrings("hello world", parsed.value.object.get("transcript").?.string);
665 try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool);
666}
667
668test "the detail does not invent keys the recognizer could have supplied" {
669 // `SFSpeechRecognitionResult` also carries `transcriptions`,
670 // `bestTranscription.segments` and per-segment `confidence`, and Swift
671 // reads none of them. Adding one would be a surface no consumer reads and
672 // a divergence from the emitter contract this migration preserves.
673 const json = try shapeResultDetail(testing.allocator, "x", true);
674 defer testing.allocator.free(json);
675
676 try testing.expect(std.mem.indexOf(u8, json, "confidence") == null);
677 try testing.expect(std.mem.indexOf(u8, json, "alternatives") == null);
678 try testing.expect(std.mem.indexOf(u8, json, "segments") == null);
679 try testing.expect(std.mem.indexOf(u8, json, "\"transcript\":") != null);
680 try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":") != null);
681}
682
683test "isFinal is a JSON boolean, never a quoted string" {
684 // `${e.detail.isFinal}` renders `"false"` and `false` identically, so a
685 // quoted value would read as correct on the test page while being truthy
686 // to any code that branches on it.
687 const json = try shapeResultDetail(testing.allocator, "", false);
688 defer testing.allocator.free(json);
689
690 try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":false") != null);
691 try testing.expect(std.mem.indexOf(u8, json, "\"false\"") == null);
692
693 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
694 defer parsed.deinit();
695 try testing.expectEqual(false, parsed.value.object.get("isFinal").?.bool);
696}
697
698test "an empty transcript still emits the key" {
699 // A first partial result can legitimately be empty. Omitting the key would
700 // leave `e.detail.transcript` undefined, which a page cannot tell from a
701 // bridge that stopped working.
702 const json = try shapeResultDetail(testing.allocator, "", false);
703 defer testing.allocator.free(json);
704
705 try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", json);
706
707 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
708 defer parsed.deinit();
709 try testing.expectEqual(@as(usize, 2), parsed.value.object.count());
710 try testing.expectEqualStrings("", parsed.value.object.get("transcript").?.string);
711}
712
713test "a transcript with a quote does not truncate the event" {
714 // The failure this shaper exists to prevent, and the one
715 // `ios_events.zig` records against `bridge_iap.zig:362`: the detail is
716 // inlined into JavaScript source, so an unescaped `"` does not corrupt one
717 // field, it ends the object early and makes the whole `dispatchEvent` call
718 // a syntax error. The page sees the stream stop, not a bad transcript.
719 //
720 // Dictation really does produce these: it transcribes spoken quotation
721 // marks, and `formattedString` is not sanitised.
722 const json = try shapeResultDetail(testing.allocator, "she said \"hello\"", true);
723 defer testing.allocator.free(json);
724
725 try testing.expectEqualStrings(
726 "{\"transcript\":\"she said \\\"hello\\\"\",\"isFinal\":true}",
727 json,
728 );
729
730 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
731 defer parsed.deinit();
732 try testing.expectEqualStrings(
733 "she said \"hello\"",
734 parsed.value.object.get("transcript").?.string,
735 );
736}
737
738test "backslashes, newlines and control bytes survive as escapes" {
739 // A literal newline inside a JavaScript string literal is a syntax error,
740 // and a lone backslash would escape whatever followed it. Both reach the
741 // page as a dead event rather than as bad text.
742 const json = try shapeResultDetail(testing.allocator, "a\\b\nc\td\r\x01", false);
743 defer testing.allocator.free(json);
744
745 try testing.expectEqualStrings(
746 "{\"transcript\":\"a\\\\b\\nc\\td\\r\\u0001\",\"isFinal\":false}",
747 json,
748 );
749
750 // No raw control byte survived into the bytes that become JS source.
751 for (json) |c| try testing.expect(c >= 0x20);
752
753 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
754 defer parsed.deinit();
755 try testing.expectEqualStrings("a\\b\nc\td\r\x01", parsed.value.object.get("transcript").?.string);
756}
757
758test "an apostrophe passes through unescaped, and must" {
759 // The opposite error, and an easy one to make while fixing the quote case.
760 // `formatEvent` single-quotes the event *name* only; the detail is an
761 // object literal, where `'` is an ordinary character inside a
762 // double-quoted JSON string. Escaping it would put a literal backslash
763 // into the transcript the page displays — and "don't" and "it's" are the
764 // most common words dictation produces.
765 const json = try shapeResultDetail(testing.allocator, "don't stop", true);
766 defer testing.allocator.free(json);
767
768 try testing.expectEqualStrings("{\"transcript\":\"don't stop\",\"isFinal\":true}", json);
769 try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null);
770
771 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
772 defer parsed.deinit();
773 try testing.expectEqualStrings("don't stop", parsed.value.object.get("transcript").?.string);
774}
775
776test "multi-byte UTF-8 is carried through unchanged" {
777 // `formattedString` is whatever locale the recognizer was built with, and
778 // even `en-US` produces curly apostrophes and em dashes. Escaping per byte
779 // rather than per code point would split a sequence and hand the page
780 // invalid JSON.
781 const spoken = "café — naïve 日本語";
782 const json = try shapeResultDetail(testing.allocator, spoken, true);
783 defer testing.allocator.free(json);
784
785 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
786 defer parsed.deinit();
787 try testing.expectEqualStrings(spoken, parsed.value.object.get("transcript").?.string);
788 try testing.expect(std.unicode.utf8ValidateSlice(json));
789}
790
791test "a shaped detail is a complete JSON value, which is what emit requires" {
792 // `ios_events.emit` documents its argument as a complete JSON value and
793 // inlines it verbatim. A shaper returning a fragment, or leaving a
794 // trailing comma, would produce a `dispatchEvent` call the page cannot
795 // parse — an event that silently never arrives.
796 const details = [_][]u8{
797 try shapeResultDetail(testing.allocator, "one", false),
798 try shapeResultDetail(testing.allocator, "", true),
799 try shapeErrorDetail(testing.allocator, err_audio_engine),
800 };
801 defer for (details) |d| testing.allocator.free(d);
802
803 for (details) |detail| {
804 try testing.expect(std.mem.startsWith(u8, detail, "{"));
805 try testing.expect(std.mem.endsWith(u8, detail, "}"));
806 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{});
807 defer parsed.deinit();
808 try testing.expect(parsed.value == .object);
809 }
810}
packages/zig/src/ios_dispatch.zigmodified+11-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -29,6 +29,15 @@ const bridge_mobile_motion = ;
2929const bridge_mobile_imagepicker = ;
3030const bridge_mobile_filepicker = ;
3131const bridge_mobile_contactpicker = ;
32const bridge_mobile_calendar = ;
33const bridge_mobile_contacts = ;
34// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
35// capability_actions table is empty — the two speech actions were researched
36// in full and left with the Swift shim — so a module here would iterate,
37// return UnknownAction, and cost a comparison to serve nothing. Keeping it in
38// the chain would also read as coverage it does not provide, which is the
39// habit this migration exists to break. The file stays as the record of that
40// decision and of the Swift behaviour a future port has to match.
3241
3342const objc = objc_runtime.objc;
3443
@@ -239,6 +248,8 @@ const mobile_bridges = .{
239248 bridge_mobile_imagepicker.ImagePickerBridge,
240249 bridge_mobile_filepicker.FilePickerBridge,
241250 bridge_mobile_contactpicker.ContactPickerBridge,
251 bridge_mobile_calendar.CalendarBridge,
252 bridge_mobile_contacts.ContactsBridge,
242253};
243254
244255/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+7-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -47,6 +47,9 @@ const zig_sources = [_][]const u8{
4747 @embedFile("src/bridge_mobile_imagepicker.zig"),
4848 @embedFile("src/bridge_mobile_filepicker.zig"),
4949 @embedFile("src/bridge_mobile_contactpicker.zig"),
50 @embedFile("src/bridge_mobile_calendar.zig"),
51 @embedFile("src/bridge_mobile_contacts.zig"),
52 @embedFile("src/bridge_mobile_speech.zig"),
5053};
5154
5255/// The action list lives in the `switch action` block, and nowhere else.
@@ -87,7 +90,10 @@ const dispatch_end = "func webView(";
8790/// 53 after the presented pickers — image, document and contact — landed on
8891/// the delegate factory, the last of the three mechanisms this migration
8992/// needed. Everything remaining is a repetition of a proven pattern.
90const max_not_yet_migrated: usize = 53;
93/// 48 after calendar and contacts. Speech was researched in full and left
94/// with the shim: neither action replies, so unlike the earlier deferrals
95/// there is no promise to strand and falling through costs the page nothing.
96const max_not_yet_migrated: usize = 48;
9197
9298fn dispatcherRegion() []const u8 {
9399 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";