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