ReviewOS

also looking at this

craft-native/craft

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

#91
Merged glennmichael123 wants to merge feat/ios-phase9-data into main
6 files +5708 -1
packages/zig/src/bridge_mobile_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}