also looking at this
iOS Phase 9: calendar and contacts — ratchet 53 → 48
#91
6 files
+5708
-1
| @@ -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 | ||
| 215 | const std = ; | |
| 216 | const builtin = ; | |
| 217 | const capabilities = ; | |
| 218 | const bridge_error = ; | |
| 219 | const objc_runtime = ; | |
| 220 | const ios_async = ; | |
| 221 | const compat_mutex = ; | |
| 222 | const memory = ; | |
| 223 | ||
| 224 | const objc = objc_runtime.objc; | |
| 225 | const 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)`. | |
| 233 | const 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. | |
| 238 | pub 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. | |
| 254 | pub 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. | |
| 263 | const Route = enum { get_events, create_event, delete_event }; | |
| 264 | ||
| 265 | fn 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 | ||
| 272 | pub 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. | |
| 412 | fn 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. | |
| 424 | fn 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. | |
| 443 | const 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. | |
| 454 | const 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. | |
| 477 | fn 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 | ||
| 493 | fn 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. | |
| 549 | fn 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. | |
| 598 | fn 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. | |
| 630 | fn 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. | |
| 669 | fn 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. | |
| 687 | fn 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. | |
| 704 | const 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. | |
| 713 | const 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 | ||
| 724 | fn 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. | |
| 749 | fn 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. | |
| 769 | fn 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. | |
| 804 | fn 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. | |
| 820 | fn 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. | |
| 833 | const 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. | |
| 838 | const 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. | |
| 846 | const 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. | |
| 851 | const 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. | |
| 855 | const 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`. | |
| 859 | const 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 | ||
| 866 | fn 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. | |
| 877 | fn 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. | |
| 905 | fn 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. | |
| 930 | const 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. | |
| 1060 | var event_store: Id = null; | |
| 1061 | var store_mutex: compat_mutex.Mutex = .{}; | |
| 1062 | ||
| 1063 | fn 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. | |
| 1089 | fn 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. | |
| 1111 | fn 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. | |
| 1132 | fn 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. | |
| 1143 | fn 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 | ||
| 1151 | fn 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. | |
| 1163 | fn 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. | |
| 1173 | fn 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. | |
| 1184 | fn 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. | |
| 1212 | fn 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. | |
| 1249 | fn 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. | |
| 1267 | fn 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. | |
| 1298 | const 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 | ||
| 1310 | const 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. | |
| 1323 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); | |
| 1324 | var 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. | |
| 1328 | fn 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. | |
| 1342 | fn 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 | ||
| 1350 | const 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. | |
| 1362 | const 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. | |
| 1373 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 1374 | ||
| 1375 | const access_block_descriptor = BlockDescriptor{ .size = @sizeOf(AccessBlock) }; | |
| 1376 | ||
| 1377 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 1378 | ||
| 1379 | /// One invoke per slot, comptime-generated so each block knows which slot it is | |
| 1380 | /// without capturing anything. | |
| 1381 | fn 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 | ||
| 1390 | fn 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 | ||
| 1403 | var access_blocks: [ios_async.max_in_flight]AccessBlock = | |
| 1404 | if (is_darwin) makeAccessBlocks() else undefined; | |
| 1405 | ||
| 1406 | fn 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. | |
| 1420 | fn 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). | |
| 1463 | fn 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. | |
| 1530 | fn 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 | ||
| 1647 | const 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. | |
| 1651 | fn resetPendingForTesting() void { | |
| 1652 | pending_mutex.lock(); | |
| 1653 | defer pending_mutex.unlock(); | |
| 1654 | pending_calls = @splat(null); | |
| 1655 | } | |
| 1656 | ||
| 1657 | fn fakeTicket(index: u5, generation: u32) ios_async.Ticket { | |
| 1658 | return .{ .index = index, .generation = generation }; | |
| 1659 | } | |
| 1660 | ||
| 1661 | fn 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 | ||
| 1671 | test "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 | ||
| 1690 | test "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 | ||
| 1699 | test "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 | ||
| 1708 | test "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 | ||
| 1734 | test "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 | ||
| 1740 | test "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 | ||
| 1777 | test "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 | ||
| 1796 | test "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 | ||
| 1808 | test "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 | ||
| 1830 | test "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 | ||
| 1842 | test "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 | ||
| 1855 | test "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 | ||
| 1867 | test "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 | ||
| 1881 | test "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 | ||
| 1897 | test "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 | ||
| 1913 | test "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 | ||
| 1934 | test "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 | ||
| 1948 | test "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 | ||
| 1964 | test "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 | ||
| 1996 | test "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 | ||
| 2016 | test "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 | ||
| 2038 | test "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 | ||
| 2046 | test "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 | ||
| 2056 | const 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 | ||
| 2066 | test "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 | ||
| 2078 | test "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 | ||
| 2094 | test "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 | ||
| 2100 | test "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 | ||
| 2125 | test "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 | ||
| 2139 | test "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 | ||
| 2163 | test "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 | ||
| 2177 | test "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 | ||
| 2199 | test "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 | ||
| 2205 | test "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 | ||
| 2216 | test "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 | ||
| 2222 | test "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 | ||
| 2229 | test "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 | ||
| 2243 | test "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 | ||
| 2257 | test "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 | ||
| 2279 | test "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 | ||
| 2296 | test "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 | ||
| 2314 | test "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 | ||
| 2337 | test "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 | ||
| 2369 | test "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 | } | |