also looking at this
iOS Phase 9: calendar and contacts — ratchet 53 → 48
#91
6 files
+5708
-1
| @@ -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 | } | |