also looking at this
iOS Phase 8: the presented pickers — ratchet 58 → 53, last mechanism landed
#90
11 files
+5752
-12
| @@ -90,7 +90,7 @@ LAUNCH_PID=$! | ||
| 90 | 90 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 91 | 91 | # fixed amount, so a slow boot does not read as a failure. |
| 92 | 92 | for _ in $(seq 1 60); do |
| 93 | if grep -q 'i=12' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi | |
| 93 | if grep -q 'i=18' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi | |
| 94 | 94 | sleep 1 |
| 95 | 95 | done |
| 96 | 96 | kill "$LAUNCH_PID" 2>/dev/null || true |
| @@ -172,4 +172,13 @@ C60="$(count 60)" | ||
| 172 | 172 | [ "$C60" -ge 1 ] || { echo "FAIL: startMotionUpdates never reached the dispatcher"; exit 1; } |
| 173 | 173 | echo "ok: event-driven action dispatched to Zig (i=60 seen ${C60}x)" |
| 174 | 174 | |
| 175 | C18="$(count 18)" | |
| 176 | # The fixture has no NSCameraUsageDescription, and presenting a camera picker | |
| 177 | # without one TERMINATES the process — so the module refuses before it ever | |
| 178 | # asks whether hardware exists, with PERMISSION_DENIED. What is asserted | |
| 179 | # is that the code is specific: the generic NATIVE_CALL_FAILED would send | |
| 180 | # whoever reads it looking for a bug that is not there. | |
| 181 | [ "$C18" -ge 1 ] || { echo "FAIL: the refusal did not name its own cause"; exit 1; } | |
| 182 | echo "ok: unavailable hardware refused with a specific code (i=18 seen ${C18}x)" | |
| 183 | ||
| 175 | 184 | echo "PASS" |
| @@ -45,11 +45,15 @@ const char craft_slice_page[] = | ||
| 45 | 45 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:3});\n" |
| 46 | 46 | "}\n" |
| 47 | 47 | "\n" |
| 48 | "var sentErrorAck = false;\n" | |
| 48 | "var sentErrorAck = false, sentCode = false;\n" | |
| 49 | 49 | "window.__craftBridgeError = function (ctx) {\n" |
| 50 | 50 | " // The message crossed Zig's escaping with a backslash, quote, and\n" |
| 51 | 51 | " // newline intact; anything mangled and this equality fails, i=7 never\n" |
| 52 | 52 | " // fires, and the harness reports the error route broken.\n" |
| 53 | " if (!sentCode && ctx && ctx.code === 'PERMISSION_DENIED' && ctx.id === 70) {\n" | |
| 54 | " sentCode = true;\n" | |
| 55 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:18});\n" | |
| 56 | " }\n" | |
| 53 | 57 | " var messageIntact = ctx && ctx.message === 'declined \\\\ \"on purpose\"\\nsecond line';\n" |
| 54 | 58 | " if (!sentErrorAck && ctx && ctx.error === true && ctx.code === 'HOST_DECLINED'\n" |
| 55 | 59 | " && ctx.id === 6 && messageIntact) {\n" |
| @@ -123,7 +127,9 @@ const char craft_slice_page[] = | ||
| 123 | 127 | " bridge.postMessage({t:'mobile', a:'getPendingNotifications', i:50});\n" " // Motion is unavailable on a simulator, so this must REJECT — an\n" |
| 124 | 128 | " // honest refusal, not a fabricated stream of zeros. i=16 stays unsent,\n" |
| 125 | 129 | " // which is the assertion.\n" |
| 126 | " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n" | |
| 130 | " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n" " // No camera on a simulator. The refusal must name its own cause —\n" | |
| 131 | " // a specific code, not the generic native-failure catch-all.\n" | |
| 132 | " bridge.postMessage({t:'mobile', a:'openCamera', i:70});\n" | |
| 127 | 133 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 128 | 134 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 129 | 135 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| @@ -458,6 +458,15 @@ pub fn build(b: *std.Build) void { | ||
| 458 | 458 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_motion.zig", .{ |
| 459 | 459 | .root_source_file = b.path("src/bridge_mobile_motion.zig"), |
| 460 | 460 | }); |
| 461 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_imagepicker.zig", .{ | |
| 462 | .root_source_file = b.path("src/bridge_mobile_imagepicker.zig"), | |
| 463 | }); | |
| 464 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_filepicker.zig", .{ | |
| 465 | .root_source_file = b.path("src/bridge_mobile_filepicker.zig"), | |
| 466 | }); | |
| 467 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_contactpicker.zig", .{ | |
| 468 | .root_source_file = b.path("src/bridge_mobile_contactpicker.zig"), | |
| 469 | }); | |
| 461 | 470 | |
| 462 | 471 | const menubar_tests = b.addTest(.{ |
| 463 | 472 | .root_module = b.createModule(.{ |
packages/zig/src/bridge_mobile_contactpicker.zigadded+1809-0
| @@ -0,0 +1,1809 @@ | ||
| 1 | //! `pickContact` — present `CNContactPickerViewController` and answer with what | |
| 2 | //! the user picked. | |
| 3 | //! | |
| 4 | //! One action, one payload field, and four delegate methods — Swift's three, | |
| 5 | //! plus one belt for a dismissal Swift has no answer to. The Swift | |
| 6 | //! original is `CraftApp.swift:969-973` (dispatch), `4301-4323` (presentation) | |
| 7 | //! and `5088-5136` (the `CNContactPickerDelegate` extension plus | |
| 8 | //! `formatContact`). Everything below is measured against those lines. | |
| 9 | //! | |
| 10 | //! ## The payload: one field, `multiple` | |
| 11 | //! | |
| 12 | //! The injected JS (`CraftApp.swift:1970-1978`) posts | |
| 13 | //! `{action:'pickContact', multiple: options.multiple || false, callbackId: id}`, | |
| 14 | //! so `multiple` is a **top-level** field of `d`. Note what `||` does and does | |
| 15 | //! not do: it is not a boolean coercion, it is a fallback. `pickContact({multiple:1})` | |
| 16 | //! puts the number `1` on the wire and `pickContact({multiple:'yes'})` puts the | |
| 17 | //! string `"yes"`. | |
| 18 | //! | |
| 19 | //! Swift then reads `body["multiple"] as? Bool ?? false`. `as? Bool` on the | |
| 20 | //! bridged `NSNumber` succeeds for exactly `0` and `1` (→ false/true) and | |
| 21 | //! returns nil for `2`, for `0.5`, for a string, for anything else — which the | |
| 22 | //! `?? false` turns into false. `multipleFrom` below is that rule, spelled out, | |
| 23 | //! and it is pinned by tests rather than left to read like a coercion. | |
| 24 | //! | |
| 25 | //! There is **no other field**. `callbackId` is the shim's own correlation and | |
| 26 | //! never reaches a Zig module — `ios_async` owns the request id here. | |
| 27 | //! | |
| 28 | //! **The page's promise has no timeout.** `pickContact` builds its `Promise` | |
| 29 | //! by hand instead of going through `_createCallback` (`CraftApp.swift:1196`, | |
| 30 | //! which carries the 30-second `setTimeout`). An unanswered call parks the page | |
| 31 | //! forever, which is why every path in this file ends in a reply or an error. | |
| 32 | //! | |
| 33 | //! ## The replies, which are three different shapes | |
| 34 | //! | |
| 35 | //! - **one contact** (`contactPicker:didSelectContact:`) resolves a JSON | |
| 36 | //! *object*: `{"id","givenName","familyName","displayName","phoneNumbers", | |
| 37 | //! "emailAddresses"}`, where `phoneNumbers` is an array of | |
| 38 | //! `{"label","number"}` and `emailAddresses` an array of | |
| 39 | //! `{"label","address"}`. Swift builds a `[String: Any]`, whose key order is | |
| 40 | //! a `Dictionary` and therefore arbitrary; one order is fixed here so the | |
| 41 | //! bytes are testable, the same call `bridge_mobile_notifications.zig` | |
| 42 | //! makes. | |
| 43 | //! - **many contacts** (`contactPicker:didSelectContacts:`) resolves a JSON | |
| 44 | //! *array* of those objects. `contacts.map` over an empty selection is `[]`, | |
| 45 | //! and that **resolves** — it is not an error and not a cancel. | |
| 46 | //! `test-bridges.html:1030` reads `contacts?.length`, so it must be an | |
| 47 | //! array. | |
| 48 | //! - **cancel** (`contactPickerDidCancel:`) **rejects**. This is the one | |
| 49 | //! action in this batch where a cancel is an error rather than a distinct | |
| 50 | //! resolve, so there is deliberately no `{"cancelled":true}` here — that | |
| 51 | //! would be a fabricated success for a pick that never happened. | |
| 52 | //! | |
| 53 | //! `packages/typescript/types/craft.d.ts:1157-1164` declares `Contact` with | |
| 54 | //! `phoneNumbers: string[]` / `emailAddresses: string[]`. That matches | |
| 55 | //! `getContacts` (flat `phone.value.stringValue` strings) and is **wrong for | |
| 56 | //! this action**, which emits `{label, number}` / `{label, address}` dicts. The | |
| 57 | //! runtime shape is the contract; `formatContact` is what is mirrored here, not | |
| 58 | //! the type declaration. | |
| 59 | //! | |
| 60 | //! ## What diverges, stated rather than smoothed over | |
| 61 | //! | |
| 62 | //! **The cancel error text and code change.** Swift calls | |
| 63 | //! `rejectCallback(id, error: "Cancelled")` with the default | |
| 64 | //! `code: "CRAFT_ERROR"`, which reaches the page as | |
| 65 | //! `{"error":true,"code":"CRAFT_ERROR",…,"message":"Cancelled"}`. Zig replies | |
| 66 | //! through `ios_async.deliverError`, which is hard-wired to | |
| 67 | //! `BridgeError.NativeCallFailed` → `code:"NATIVE_CALL_FAILED"`, | |
| 68 | //! `message:"Native API call failed"`. The page's `catch` still runs and the | |
| 69 | //! promise still settles, so nothing hangs and nothing is fabricated — but a | |
| 70 | //! page that prints `err.message` (which `test-bridges.html:1022` does) shows | |
| 71 | //! different text, and a page that branches on `err.code` sees a different code. | |
| 72 | //! | |
| 73 | //! This is the honest floor of the available channel, not a preference. | |
| 74 | //! `bridge_error.BridgeError.Cancelled` → `CANCELLED` / | |
| 75 | //! `"Operation was cancelled"` would be *nearer* the truth, and `ios_async` has | |
| 76 | //! no way to send it: `deliverError` sets a `failed` flag and `deliverOnMain` | |
| 77 | //! chooses `NativeCallFailed` for every flagged slot. Closing the gap is a | |
| 78 | //! change to `ios_async.zig` — a `BridgeError` stored on the slot beside | |
| 79 | //! `failed`, and `deliverOnMain` sending that instead of the constant — and it | |
| 80 | //! belongs there rather than in a second reply path built here, because a | |
| 81 | //! module that reached around `ios_async` would also be reaching around the | |
| 82 | //! generation guard that stops a stale callback answering someone else's call. | |
| 83 | //! Until then, "the native call failed" is what a cancel says, and saying so | |
| 84 | //! here is the point. | |
| 85 | //! | |
| 86 | //! **`config.enableContacts` has no Zig mirror.** `ios.zig`'s `AppConfig` has | |
| 87 | //! no such field — the same gap `bridge_mobile_system.zig` records for | |
| 88 | //! `enableShare`. The Info.plist is read instead, and for this flag the mapping | |
| 89 | //! is exact: `packages/ios/src/index.ts:189` writes `NSContactsUsageDescription` | |
| 90 | //! if and only if `config.enableContacts`, with none of the sharing that made | |
| 91 | //! the location keys ambiguous. | |
| 92 | //! | |
| 93 | //! Two things about that gate have to be said plainly. First, the key is **not | |
| 94 | //! a precondition of the API**: `CNContactPickerViewController` runs out of | |
| 95 | //! process and needs no Contacts authorization, so this reads the key purely as | |
| 96 | //! *evidence of the flag*, never as something the framework requires. Second, | |
| 97 | //! the behaviour changes: Swift's `case "pickContact":` has **no `else`**, so an | |
| 98 | //! app built with `enableContacts: false` answers the page with *nothing*, on a | |
| 99 | //! promise with no timeout — a hang for the life of the page. Here it is an | |
| 100 | //! explicit `PERMISSION_DENIED`. Everybody settles, which is strictly better, | |
| 101 | //! and it is a difference a page can observe. | |
| 102 | //! | |
| 103 | //! **No permission is requested, deliberately.** `pickContact` never touches | |
| 104 | //! `CNContactStore` and never calls `requestAccess(for:.contacts)`; only | |
| 105 | //! `getContacts` and `addContact` do. Adding one here would show the user a | |
| 106 | //! prompt the shim never shows. | |
| 107 | //! | |
| 108 | //! **Nothing is dismissed, and that is a decision rather than an omission.** | |
| 109 | //! What is *verified*: `CraftApp.swift` contains exactly five `dismiss(` calls — | |
| 110 | //! `UIImagePickerController` (2643, 2662), the PDF viewer (4288, 4294) and the | |
| 111 | //! data scanner (4873) — and none of them is in the contact-picker path. Swift's | |
| 112 | //! three `CNContactPickerDelegate` methods return without dismissing anything. | |
| 113 | //! What is *reasoned*: `CNContactPickerViewController` is an out-of-process | |
| 114 | //! remote view controller, which is the standard explanation for why it takes | |
| 115 | //! itself down on both Done and Cancel and why its delegate protocol asks for no | |
| 116 | //! dismissal. | |
| 117 | //! | |
| 118 | //! So this file matches the shim exactly, and matching it is the conservative | |
| 119 | //! choice in both directions. Adding a dismissal would be a *second* dismissal | |
| 120 | //! of a controller already dismissing, and sent to the presenter it would take | |
| 121 | //! down whatever else happened to be modal — a new bug, in an action the shim | |
| 122 | //! answers. Omitting it can only reproduce a defect the shim already has. This | |
| 123 | //! is the one place where the generic picker rule "the delegate must dismiss" | |
| 124 | //! does not apply; the image and document pickers are `UIImagePickerController` | |
| 125 | //! and `UIDocumentPickerViewController`, both in-process, both of which do need | |
| 126 | //! it. | |
| 127 | //! | |
| 128 | //! Not verified on a device from here: that the picker really does disappear | |
| 129 | //! without help. If it turns out not to, the fix is one `dismiss` sent to the | |
| 130 | //! *picker* (never the presenter) from each of the picker callbacks. | |
| 131 | //! | |
| 132 | //! **A contact whose keys were not fetched still crashes.** Touching an | |
| 133 | //! unfetched property raises `CNContactPropertyNotFetchedException`, which from | |
| 134 | //! Zig is an uncatchable SIGABRT. Swift cannot catch `NSException` either, so | |
| 135 | //! this is exact parity with the shim rather than a regression — and there is | |
| 136 | //! deliberately no comment below claiming a guard against it, because there | |
| 137 | //! isn't one. | |
| 138 | //! | |
| 139 | //! ## Concurrency: one picker, one ticket, and a refusal that says so | |
| 140 | //! | |
| 141 | //! Swift keeps a single `pendingCallbackId` (line 383) shared by *every* picker | |
| 142 | //! in the coordinator — camera, document, NFC, Bluetooth, contacts. Opening any | |
| 143 | //! second picker overwrites it and the first promise never settles. This module | |
| 144 | //! keeps its own ticket, and when a second `pickContact` arrives while one is on | |
| 145 | //! screen it **refuses the second and leaves the first alone**: the first picker | |
| 146 | //! is still presented and will still answer, so displacing it would strand a | |
| 147 | //! call that was about to succeed. | |
| 148 | //! | |
| 149 | //! What that costs, said rather than hidden: the slot is cleared only by a | |
| 150 | //! delegate callback. A picker that is never presented, or presented and then | |
| 151 | //! never answered, leaves the slot held for the life of the process and refuses | |
| 152 | //! every later call. Two causes are known, and they are not both removed: | |
| 153 | //! | |
| 154 | //! - **Presenting on a controller that is already presenting**, which UIKit | |
| 155 | //! answers by logging and doing nothing. That one is removed: | |
| 156 | //! `bridge_mobile_system.uikit.topmostViewController()`'s walk up | |
| 157 | //! `presentedViewController` is why this file reuses that function rather | |
| 158 | //! than copying Swift's `windows.first?.rootViewController` | |
| 159 | //! (`CraftApp.swift:4303-4304`), which has the bug. | |
| 160 | //! - **A swipe-down dismissal.** The picker is a sheet on iOS 13+, and an | |
| 161 | //! interactive dismissal is widely reported not to call | |
| 162 | //! `contactPickerDidCancel:` — the same report | |
| 163 | //! `bridge_mobile_imagepicker.zig` carries a belt for, and one this repo | |
| 164 | //! verifies on no device. `presentationControllerDidDismiss:` is that belt | |
| 165 | //! here too: it is registered on the delegate, and the picker's | |
| 166 | //! presentation controller is pointed at that delegate before presenting, | |
| 167 | //! so a swipe rejects the call instead of stranding it. Belt, not fix — | |
| 168 | //! UIKit sends it only for a user-driven dismissal, so it cannot fire after | |
| 169 | //! a pick or a Cancel that already answered, and if the report is wrong it | |
| 170 | //! costs two messages. The one case it does not cover is a presentation | |
| 171 | //! controller that already carries a delegate of its own, which is left | |
| 172 | //! alone rather than displaced; that is logged where it happens rather than | |
| 173 | //! assumed away. | |
| 174 | //! | |
| 175 | //! No reply is ever sent from a delegate callback directly. `ios_async` is the | |
| 176 | //! only channel, and the main-queue hop it performs is not merely thread | |
| 177 | //! safety: it is what restores the `request_context` captured at dispatch, so | |
| 178 | //! the reply names the call that is waiting instead of falling back to | |
| 179 | //! action-name matching. | |
| 180 | ||
| 181 | const std = ; | |
| 182 | const builtin = ; | |
| 183 | const capabilities = ; | |
| 184 | const bridge_error = ; | |
| 185 | const objc_runtime = ; | |
| 186 | const ios_async = ; | |
| 187 | const ios_delegate = ; | |
| 188 | const bridge_mobile_system = ; | |
| 189 | const compat_mutex = ; | |
| 190 | ||
| 191 | const objc = objc_runtime.objc; | |
| 192 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 193 | ||
| 194 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the | |
| 195 | /// location/notifications precedent: `objc_runtime.objc` is an empty struct off | |
| 196 | /// Darwin and a function *signature* is analysed even when a comptime platform | |
| 197 | /// guard prunes the body, so naming `objc.id` in the `callconv(.c)` types below | |
| 198 | /// would break the host build. A single optional pointer, never `?objc.id` — a | |
| 199 | /// double optional is illegal in `callconv(.c)`. | |
| 200 | const Id = ?*anyopaque; | |
| 201 | ||
| 202 | /// The action name, spelled exactly as the Swift `case` label spells it. | |
| 203 | /// `test/ios_conformance_test.zig` matches this block against those labels in | |
| 204 | /// both directions, and fails the build if two modules declare one name. | |
| 205 | pub const A = struct { | |
| 206 | pub const pick_contact = "pickContact"; | |
| 207 | }; | |
| 208 | ||
| 209 | /// `.result`: every Swift path out of this action terminates in exactly one | |
| 210 | /// `resolveCallback` (either `didSelect` overload) or one `rejectCallback` | |
| 211 | /// (cancel). `.none` would be a claim that nothing answers, and on this | |
| 212 | /// particular promise — hand-built, no `setTimeout` — that is a page parked | |
| 213 | /// forever rather than for thirty seconds. | |
| 214 | /// | |
| 215 | /// `.live`, not `.unavailable`: in an app built with `enableContacts` the | |
| 216 | /// picker presents and answers. A refusal here is always a specific condition | |
| 217 | /// (contacts not configured, ContactsUI not linked, a picker already on screen, | |
| 218 | /// the async pool full), never this action's normal answer. | |
| 219 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 220 | .{ .name = A.pick_contact, .reply = .result }, | |
| 221 | }; | |
| 222 | ||
| 223 | /// Which handler an action selects, split out from `handleMessage` so the | |
| 224 | /// table-versus-dispatch agreement is assertable on a host without touching | |
| 225 | /// ContactsUI. | |
| 226 | const Route = enum { pick_contact }; | |
| 227 | ||
| 228 | fn routeFor(action: []const u8) ?Route { | |
| 229 | if (std.mem.eql(u8, action, A.pick_contact)) return .pick_contact; | |
| 230 | return null; | |
| 231 | } | |
| 232 | ||
| 233 | pub const ContactPickerBridge = struct { | |
| 234 | allocator: std.mem.Allocator, | |
| 235 | ||
| 236 | const Self = @This(); | |
| 237 | ||
| 238 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 239 | return .{ .allocator = allocator }; | |
| 240 | } | |
| 241 | ||
| 242 | pub fn deinit(_: *Self) void {} | |
| 243 | ||
| 244 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 245 | const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; | |
| 246 | // Exhaustive, so a `Route` without a handler is a compile error. | |
| 247 | return switch (route) { | |
| 248 | .pick_contact => self.pickContact(data), | |
| 249 | }; | |
| 250 | } | |
| 251 | ||
| 252 | /// Present the contact picker and hand the answer to the delegate. | |
| 253 | /// | |
| 254 | /// Ordering is load-bearing in two places. The payload is parsed *first*, so | |
| 255 | /// a malformed body is reported the same on any platform and before any | |
| 256 | /// framework is consulted. And every fallible step — the Info.plist gate, | |
| 257 | /// both framework classes, every selector a delegate callback or the | |
| 258 | /// swipe-down belt will need, the delegate class, the presenter, the | |
| 259 | /// predicate, the picker itself — runs **before** `ios_async.acquire`. | |
| 260 | /// Exactly one path leaves this function after the lease without presenting | |
| 261 | /// — the concurrency refusal — and it releases the slot with | |
| 262 | /// `ios_async.abandon` on its way out. Every other statement between the | |
| 263 | /// lease and `presentViewController:` is infallible. That is the property | |
| 264 | /// being protected: a lease that escapes without either a presentation or an | |
| 265 | /// `abandon` narrows the pool for the life of the process. | |
| 266 | fn pickContact(self: *Self, data: []const u8) !void { | |
| 267 | const multiple = try parseMultiple(self.allocator, data); | |
| 268 | ||
| 269 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 270 | ||
| 271 | try requireContactsConfigured(); | |
| 272 | ||
| 273 | // ContactsUI first, Contacts second, each guarded on its own: a process | |
| 274 | // that linked Contacts but not ContactsUI is a real configuration, and | |
| 275 | // the fixture (`packages/ios/fixtures/zig-slice/build-and-run.sh:65`) | |
| 276 | // links neither. Naming the missing class beats a generic failure, | |
| 277 | // because the fix is a link line rather than anything in this file. | |
| 278 | const PickerClass = objc.objc_getClass("CNContactPickerViewController") orelse { | |
| 279 | std.log.err( | |
| 280 | "pickContact refused: this process has no CNContactPickerViewController; " ++ | |
| 281 | "ContactsUI.framework is not linked", | |
| 282 | .{}, | |
| 283 | ); | |
| 284 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 285 | }; | |
| 286 | ||
| 287 | const sels = try Sels.resolve(); | |
| 288 | const delegate = try ensureDelegate(); | |
| 289 | ||
| 290 | // The walk up `presentedViewController` is the part Swift is missing — | |
| 291 | // see the module comment. It also matters to the refusal policy: one of | |
| 292 | // the two known ways to leave a ticket unanswered forever is a | |
| 293 | // presentation UIKit silently declines because the target is already | |
| 294 | // presenting, and the walk is what stops this file ever asking for that. | |
| 295 | // The other — a swipe-down that sends no delegate callback — is what | |
| 296 | // `attachDismissObserver` below is for. | |
| 297 | const presenter = try bridge_mobile_system.uikit.topmostViewController(); | |
| 298 | ||
| 299 | const NSPredicate = objc.objc_getClass("NSPredicate") orelse return error.ClassNotFound; | |
| 300 | const sel_predicate_with_value = try selector("predicateWithValue:"); | |
| 301 | const sel_set_delegate = try selector("setDelegate:"); | |
| 302 | const sel_set_enabling = try selector("setPredicateForEnablingContact:"); | |
| 303 | const sel_set_selection = try selector("setPredicateForSelectionOfContact:"); | |
| 304 | const sel_present = try selector("presentViewController:animated:completion:"); | |
| 305 | // The swipe-down belt's two reads, resolved here with everything else: | |
| 306 | // a missing selector then fails the call rather than silently leaving | |
| 307 | // the only thing that answers an interactive dismissal unattached. | |
| 308 | const dismiss_sels = DismissSels{ | |
| 309 | .presentation = try selector("presentationController"), | |
| 310 | .delegate = try selector("delegate"), | |
| 311 | .set_delegate = sel_set_delegate, | |
| 312 | }; | |
| 313 | ||
| 314 | // `+[NSPredicate predicateWithValue:]` takes a `BOOL`, so the cast has | |
| 315 | // to name that argument; `msgSendId1` builds the signature from | |
| 316 | // `@TypeOf(arg1)` and `true` is a Zig `bool`, which is what `B` is on | |
| 317 | // 64-bit Apple platforms. | |
| 318 | const always_true = objc.msgSendId1(NSPredicate, sel_predicate_with_value, true) orelse | |
| 319 | return error.NativeCallFailed; | |
| 320 | ||
| 321 | const picker = (try objc.allocInit(PickerClass)) orelse return error.NativeCallFailed; | |
| 322 | // `allocInit` gave us +1. `presentViewController:` retains the picker for | |
| 323 | // the life of the presentation, so ours is the reference to drop — and | |
| 324 | // on the refusal paths below it is the only one there is. Same shape as | |
| 325 | // the share sheet's `defer objc.release(activity_vc)`. | |
| 326 | defer objc.release(picker); | |
| 327 | ||
| 328 | objc.msgSendVoid1(picker, sel_set_delegate, delegate); | |
| 329 | objc.msgSendVoid1(picker, sel_set_enabling, always_true); | |
| 330 | ||
| 331 | if (multiple) { | |
| 332 | // Swift's `picker.predicateForSelectionOfContact = nil`. The | |
| 333 | // `@as(objc.id, null)` is required: an untyped `null` gives | |
| 334 | // `msgSendVoid1` no argument type to build a signature from. | |
| 335 | objc.msgSendVoid1(picker, sel_set_selection, @as(objc.id, null)); | |
| 336 | } else { | |
| 337 | // Swift builds a second `NSPredicate(value: true)` here. One | |
| 338 | // instance serves both properties identically — `NSPredicate` is | |
| 339 | // immutable and neither property mutates it — so the object graph | |
| 340 | // is the same one Swift assembles in two allocations. | |
| 341 | objc.msgSendVoid1(picker, sel_set_selection, always_true); | |
| 342 | } | |
| 343 | ||
| 344 | // Attached before presenting, because that is when a presentation | |
| 345 | // controller can be configured, and retried after `presentViewController:` | |
| 346 | // for the case where UIKit only builds one there. Infallible either way: | |
| 347 | // it returns whether it took, and never fails the call. | |
| 348 | var dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels); | |
| 349 | ||
| 350 | const ticket = ios_async.acquire(A.pick_contact) orelse return poolFull(); | |
| 351 | ||
| 352 | // Published *before* the framework call, never after: a delegate that | |
| 353 | // somehow reached an empty slot would have no ticket to reply with. The | |
| 354 | // publish is also the concurrency check, done under one lock so two | |
| 355 | // dispatches cannot both find the slot free. | |
| 356 | if (!publishPending(.{ .ticket = ticket, .sels = sels })) { | |
| 357 | ios_async.abandon(ticket); | |
| 358 | return alreadyPresented(); | |
| 359 | } | |
| 360 | ||
| 361 | const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void; | |
| 362 | const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend); | |
| 363 | // Completion handler nil, as Swift's `present(picker, animated: true)` | |
| 364 | // passes none. There is nothing to do on presentation-complete: the | |
| 365 | // answer comes from the delegate. | |
| 366 | present_fn(presenter, sel_present, picker, true, null); | |
| 367 | ||
| 368 | if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels); | |
| 369 | if (!dismiss_observed) { | |
| 370 | std.log.warn( | |
| 371 | "pickContact: the picker's presentation controller could not be observed " ++ | |
| 372 | "(absent, or already carrying a delegate this file will not displace); " ++ | |
| 373 | "if a swipe-down dismissal does not send contactPickerDidCancel: then " ++ | |
| 374 | "this call stays unanswered and every later one is refused", | |
| 375 | .{}, | |
| 376 | ); | |
| 377 | } | |
| 378 | } | |
| 379 | }; | |
| 380 | ||
| 381 | /// The answer for a full block pool, copied from `bridge_mobile_location`: | |
| 382 | /// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes' | |
| 383 | /// designated stand-in, and the point is that the caller gets an explicit | |
| 384 | /// rejection instead of a promise that never settles. | |
| 385 | fn poolFull() bridge_error.BridgeError { | |
| 386 | std.log.warn( | |
| 387 | "pickContact refused: all {d} async slots are in flight", | |
| 388 | .{ios_async.max_in_flight}, | |
| 389 | ); | |
| 390 | return bridge_error.BridgeError.InvalidParameter; | |
| 391 | } | |
| 392 | ||
| 393 | /// The answer for a second `pickContact` while one is on screen. | |
| 394 | /// | |
| 395 | /// Deliberately *not* displacement. `bridge_mobile_location.zig` rejects the | |
| 396 | /// displaced first caller because CoreLocation's one-shot may genuinely never | |
| 397 | /// answer; a presented picker is the opposite situation — it is on screen, the | |
| 398 | /// user is looking at it, and it will answer. Refusing the newcomer keeps that | |
| 399 | /// promise alive. Swift does neither: it overwrites `pendingCallbackId` and the | |
| 400 | /// first caller waits forever on an untimed promise. | |
| 401 | /// | |
| 402 | /// Same `INVALID_PARAMETER` stand-in as `poolFull`, for the same reason — | |
| 403 | /// `BridgeError` has no word for "busy" — with a log line that says which of | |
| 404 | /// the two happened. | |
| 405 | fn alreadyPresented() bridge_error.BridgeError { | |
| 406 | std.log.warn( | |
| 407 | "pickContact refused: a contact picker is already presented and still waiting for an " ++ | |
| 408 | "answer; the first call is left alone rather than being stranded", | |
| 409 | .{}, | |
| 410 | ); | |
| 411 | return bridge_error.BridgeError.InvalidParameter; | |
| 412 | } | |
| 413 | ||
| 414 | // ============================================================================= | |
| 415 | // Payload parsing. Pure — no Objective-C — so the exact coercion Swift performs | |
| 416 | // is pinned by host tests on every platform. | |
| 417 | // ============================================================================= | |
| 418 | ||
| 419 | /// `multiple`, read the way `body["multiple"] as? Bool ?? false` reads it. | |
| 420 | /// | |
| 421 | /// A malformed or non-object `d` is `InvalidJSON` rather than a silent `false`. | |
| 422 | /// It is a Zig-only failure mode — `WKScriptMessage.body` is always a | |
| 423 | /// dictionary, so Swift never meets it — but guessing `false` for a body that | |
| 424 | /// could not be read would be acting on a default the page did not send. | |
| 425 | fn parseMultiple(allocator: std.mem.Allocator, data: []const u8) !bool { | |
| 426 | const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch | |
| 427 | return bridge_error.BridgeError.InvalidJSON; | |
| 428 | defer parsed.deinit(); | |
| 429 | ||
| 430 | const root = switch (parsed.value) { | |
| 431 | .object => |obj| obj, | |
| 432 | else => return bridge_error.BridgeError.InvalidJSON, | |
| 433 | }; | |
| 434 | ||
| 435 | return multipleFrom(root.get("multiple")); | |
| 436 | } | |
| 437 | ||
| 438 | /// The `as? Bool ?? false` rule, as a total function. | |
| 439 | /// | |
| 440 | /// `options.multiple || false` in the injected JS is a fallback, not a | |
| 441 | /// coercion, so anything truthy reaches the wire unchanged: `1`, `"yes"`, `{}`. | |
| 442 | /// Swift's bridged `NSNumber as? Bool` then succeeds for exactly `0` and `1` | |
| 443 | /// and yields nil for everything else, which `?? false` collapses to false. So | |
| 444 | /// `pickContact({multiple: 2})` and `pickContact({multiple: 'yes'})` both open | |
| 445 | /// a *single*-selection picker, and reproducing that is the contract — reading | |
| 446 | /// them as "truthy, therefore multiple" would open a different picker than the | |
| 447 | /// shim opens for the same call. | |
| 448 | fn multipleFrom(value: ?std.json.Value) bool { | |
| 449 | const v = value orelse return false; | |
| 450 | return switch (v) { | |
| 451 | .bool => |b| b, | |
| 452 | .integer => |i| i == 1, | |
| 453 | // `NSNumber(value: 1.0) as? Bool` is true, `0.5` is nil. Only an exact | |
| 454 | // 1 is a yes. | |
| 455 | .float => |f| f == 1.0, | |
| 456 | // A string, an array, an object, JSON null, or a number too large for | |
| 457 | // an i64 (`number_string`). None of them survive `as? Bool`. | |
| 458 | else => false, | |
| 459 | }; | |
| 460 | } | |
| 461 | ||
| 462 | // ============================================================================= | |
| 463 | // Reply shaping. Pure — no Objective-C — so the exact bytes the page receives | |
| 464 | // are pinned by host tests. | |
| 465 | // ============================================================================= | |
| 466 | ||
| 467 | /// One `CNLabeledValue`, flattened. | |
| 468 | /// | |
| 469 | /// `value` is the phone number's `stringValue` or the email address; which wire | |
| 470 | /// key it lands under — `number` or `address` — is decided by the writer, not | |
| 471 | /// stored here, because `formatContact` uses two different key names for the | |
| 472 | /// same shape and getting them swapped is silent. | |
| 473 | const Labeled = struct { | |
| 474 | label: []const u8, | |
| 475 | value: []const u8, | |
| 476 | }; | |
| 477 | ||
| 478 | /// One contact, exactly as `formatContact` assembles it. | |
| 479 | /// | |
| 480 | /// The strings borrow whatever buffer they were read from. On the native path | |
| 481 | /// that is an `NSString`'s internal UTF-8 buffer, valid for the delegate | |
| 482 | /// callback's autorelease pool, and every one of them is copied into the JSON | |
| 483 | /// before the callback returns. | |
| 484 | const Contact = struct { | |
| 485 | id: []const u8, | |
| 486 | given_name: []const u8, | |
| 487 | family_name: []const u8, | |
| 488 | display_name: []const u8, | |
| 489 | phones: []const Labeled, | |
| 490 | emails: []const Labeled, | |
| 491 | }; | |
| 492 | ||
| 493 | fn appendJsonString( | |
| 494 | allocator: std.mem.Allocator, | |
| 495 | out: *std.ArrayListUnmanaged(u8), | |
| 496 | s: []const u8, | |
| 497 | ) !void { | |
| 498 | try out.append(allocator, '"'); | |
| 499 | // A contact's name, label and number are user data and will contain `"` and | |
| 500 | // `\` in the wild. This reply is replayed into the source | |
| 501 | // `evaluateJavaScript:` parses, so an unescaped quote is a syntax error in | |
| 502 | // the page rather than a wrong field. | |
| 503 | try bridge_error.appendJsonEscaped(allocator, out, s); | |
| 504 | try out.append(allocator, '"'); | |
| 505 | } | |
| 506 | ||
| 507 | fn appendLabeledArray( | |
| 508 | allocator: std.mem.Allocator, | |
| 509 | out: *std.ArrayListUnmanaged(u8), | |
| 510 | items: []const Labeled, | |
| 511 | comptime value_key: []const u8, | |
| 512 | ) !void { | |
| 513 | try out.append(allocator, '['); | |
| 514 | for (items, 0..) |item, i| { | |
| 515 | if (i != 0) try out.append(allocator, ','); | |
| 516 | try out.appendSlice(allocator, "{\"label\":"); | |
| 517 | try appendJsonString(allocator, out, item.label); | |
| 518 | try out.appendSlice(allocator, ",\"" ++ value_key ++ "\":"); | |
| 519 | try appendJsonString(allocator, out, item.value); | |
| 520 | try out.append(allocator, '}'); | |
| 521 | } | |
| 522 | try out.append(allocator, ']'); | |
| 523 | } | |
| 524 | ||
| 525 | /// One contact as a JSON object, in a fixed key order. | |
| 526 | /// | |
| 527 | /// Swift's `[String: Any]` has no order at all; fixing one here is what makes | |
| 528 | /// the bytes testable. The two `value_key` spellings are the part worth | |
| 529 | /// watching: phones are `number`, emails are `address`, and `formatContact` is | |
| 530 | /// the only authority for that. | |
| 531 | fn appendContact( | |
| 532 | allocator: std.mem.Allocator, | |
| 533 | out: *std.ArrayListUnmanaged(u8), | |
| 534 | contact: Contact, | |
| 535 | ) !void { | |
| 536 | try out.appendSlice(allocator, "{\"id\":"); | |
| 537 | try appendJsonString(allocator, out, contact.id); | |
| 538 | try out.appendSlice(allocator, ",\"givenName\":"); | |
| 539 | try appendJsonString(allocator, out, contact.given_name); | |
| 540 | try out.appendSlice(allocator, ",\"familyName\":"); | |
| 541 | try appendJsonString(allocator, out, contact.family_name); | |
| 542 | try out.appendSlice(allocator, ",\"displayName\":"); | |
| 543 | try appendJsonString(allocator, out, contact.display_name); | |
| 544 | try out.appendSlice(allocator, ",\"phoneNumbers\":"); | |
| 545 | try appendLabeledArray(allocator, out, contact.phones, "number"); | |
| 546 | try out.appendSlice(allocator, ",\"emailAddresses\":"); | |
| 547 | try appendLabeledArray(allocator, out, contact.emails, "address"); | |
| 548 | try out.append(allocator, '}'); | |
| 549 | } | |
| 550 | ||
| 551 | /// The `didSelectContact:` reply: a bare object. | |
| 552 | fn shapeContact(allocator: std.mem.Allocator, contact: Contact) ![]u8 { | |
| 553 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 554 | errdefer out.deinit(allocator); | |
| 555 | try appendContact(allocator, &out, contact); | |
| 556 | return out.toOwnedSlice(allocator); | |
| 557 | } | |
| 558 | ||
| 559 | /// The `didSelectContacts:` reply: an array of objects. | |
| 560 | /// | |
| 561 | /// An empty selection is `[]`, which **resolves**. `contacts.map` over an empty | |
| 562 | /// array is `[]` in Swift and `test-bridges.html` reads `.length` off it, so | |
| 563 | /// turning "nothing selected" into an error or a cancel would break a path the | |
| 564 | /// shim answers successfully. | |
| 565 | fn shapeContacts(allocator: std.mem.Allocator, contacts: []const Contact) ![]u8 { | |
| 566 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 567 | errdefer out.deinit(allocator); | |
| 568 | ||
| 569 | try out.append(allocator, '['); | |
| 570 | for (contacts, 0..) |contact, i| { | |
| 571 | if (i != 0) try out.append(allocator, ','); | |
| 572 | try appendContact(allocator, &out, contact); | |
| 573 | } | |
| 574 | try out.append(allocator, ']'); | |
| 575 | ||
| 576 | return out.toOwnedSlice(allocator); | |
| 577 | } | |
| 578 | ||
| 579 | // ============================================================================= | |
| 580 | // Objective-C: everything looked up once at dispatch, then a walk that cannot | |
| 581 | // look anything up. | |
| 582 | // ============================================================================= | |
| 583 | ||
| 584 | /// The Info.plist key `packages/ios/src/index.ts:189` writes if and only if | |
| 585 | /// `config.enableContacts`. | |
| 586 | const key_contacts_usage = "NSContactsUsageDescription"; | |
| 587 | ||
| 588 | /// `NSUTF8StringEncoding`. Used only to ask a string how long it really is, so | |
| 589 | /// a NUL-truncated read can be told from a short string. | |
| 590 | const ns_utf8_string_encoding: c_ulong = 4; | |
| 591 | ||
| 592 | /// `CNContactFormatterStyleFullName`. A header enum constant with `FullName = 0` | |
| 593 | /// and `PhoneticFullName = 1`, so this one is safe to spell out rather than | |
| 594 | /// `dlsym`. | |
| 595 | const cn_contact_formatter_style_full_name: c_long = 0; | |
| 596 | ||
| 597 | fn selector(name: [*:0]const u8) !Id { | |
| 598 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 599 | return objc.sel_registerName(name) orelse error.SelectorNotFound; | |
| 600 | } | |
| 601 | ||
| 602 | /// The main bundle's Info.plist value for `key`, or null when it has none. | |
| 603 | /// | |
| 604 | /// Errors rather than answering null when the runtime itself will not | |
| 605 | /// cooperate: "there is no NSBundle class" and "this app was not built with | |
| 606 | /// contacts enabled" are different facts, and collapsing them would blame the | |
| 607 | /// app's configuration for a broken process. Same shape as | |
| 608 | /// `bridge_mobile_location.zig`'s gate, which is private to that file. | |
| 609 | fn infoPlistValue(comptime key: [*:0]const u8) !Id { | |
| 610 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 611 | ||
| 612 | const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; | |
| 613 | const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; | |
| 614 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle; | |
| 615 | ||
| 616 | const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound; | |
| 617 | const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse | |
| 618 | return error.SelectorNotFound; | |
| 619 | const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed; | |
| 620 | ||
| 621 | const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse | |
| 622 | return error.SelectorNotFound; | |
| 623 | return objc.msgSendId1(bundle, sel_lookup, ns_key); | |
| 624 | } | |
| 625 | ||
| 626 | /// Refuse when the app was not built with `enableContacts`. | |
| 627 | /// | |
| 628 | /// Checked first, matching Swift's `if config.enableContacts` guarding the whole | |
| 629 | /// case. The key is evidence of that flag and nothing more — see the module | |
| 630 | /// comment: `CNContactPickerViewController` needs no Contacts authorization, so | |
| 631 | /// this is not the framework's precondition being enforced. | |
| 632 | fn requireContactsConfigured() !void { | |
| 633 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 634 | ||
| 635 | if ((try infoPlistValue(key_contacts_usage)) == null) { | |
| 636 | std.log.warn( | |
| 637 | "pickContact refused: Info.plist has no {s}, so this app was not built with " ++ | |
| 638 | "contacts enabled", | |
| 639 | .{key_contacts_usage}, | |
| 640 | ); | |
| 641 | return bridge_error.BridgeError.PermissionDenied; | |
| 642 | } | |
| 643 | } | |
| 644 | ||
| 645 | /// Everything a delegate callback needs, resolved while a synchronous error can | |
| 646 | /// still reach the page. | |
| 647 | /// | |
| 648 | /// The callbacks run long after the dispatch frame is gone, where a failed | |
| 649 | /// `sel_registerName` could only be logged. Resolving here turns that whole | |
| 650 | /// class of failure into an ordinary rejection. | |
| 651 | /// | |
| 652 | /// Two *class* objects ride along for the same reason. `CNContactFormatter` and | |
| 653 | /// `CNLabeledValue` are used as receivers of class methods | |
| 654 | /// (`stringFromContact:style:`, `localizedStringForLabel:`), so a missing | |
| 655 | /// Contacts.framework has to be found here rather than inside the callback. | |
| 656 | /// They are guarded separately from `CNContactPickerViewController` because | |
| 657 | /// they come from a different framework. | |
| 658 | const Sels = struct { | |
| 659 | // Contacts.framework classes, as receivers of class methods. | |
| 660 | formatter_class: Id, | |
| 661 | labeled_value_class: Id, | |
| 662 | string_class: Id, | |
| 663 | ||
| 664 | // NSArray | |
| 665 | count: Id, | |
| 666 | object_at: Id, | |
| 667 | ||
| 668 | // CNContact | |
| 669 | identifier: Id, | |
| 670 | given_name: Id, | |
| 671 | family_name: Id, | |
| 672 | phone_numbers: Id, | |
| 673 | email_addresses: Id, | |
| 674 | ||
| 675 | // CNContactFormatter (class method) | |
| 676 | string_from_contact: Id, | |
| 677 | ||
| 678 | // CNLabeledValue. One class, not two: the Swift generics | |
| 679 | // `CNLabeledValue<CNPhoneNumber>` and `CNLabeledValue<NSString>` are the | |
| 680 | // same Objective-C class. | |
| 681 | label: Id, | |
| 682 | value: Id, | |
| 683 | localized_string_for_label: Id, | |
| 684 | ||
| 685 | // CNPhoneNumber | |
| 686 | string_value: Id, | |
| 687 | ||
| 688 | // NSString | |
| 689 | empty_string: Id, | |
| 690 | utf8: Id, | |
| 691 | length_of_bytes: Id, | |
| 692 | ||
| 693 | fn resolve() !Sels { | |
| 694 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 695 | ||
| 696 | const formatter = objc.objc_getClass("CNContactFormatter") orelse { | |
| 697 | std.log.err( | |
| 698 | "pickContact refused: this process has no CNContactFormatter; " ++ | |
| 699 | "Contacts.framework is not linked", | |
| 700 | .{}, | |
| 701 | ); | |
| 702 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 703 | }; | |
| 704 | const labeled = objc.objc_getClass("CNLabeledValue") orelse { | |
| 705 | std.log.err( | |
| 706 | "pickContact refused: this process has no CNLabeledValue; " ++ | |
| 707 | "Contacts.framework is not linked", | |
| 708 | .{}, | |
| 709 | ); | |
| 710 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 711 | }; | |
| 712 | const string_class = objc.objc_getClass("NSString") orelse return error.ClassNotFound; | |
| 713 | ||
| 714 | return .{ | |
| 715 | .formatter_class = formatter, | |
| 716 | .labeled_value_class = labeled, | |
| 717 | .string_class = string_class, | |
| 718 | ||
| 719 | .count = try selector("count"), | |
| 720 | .object_at = try selector("objectAtIndex:"), | |
| 721 | ||
| 722 | .identifier = try selector("identifier"), | |
| 723 | .given_name = try selector("givenName"), | |
| 724 | .family_name = try selector("familyName"), | |
| 725 | .phone_numbers = try selector("phoneNumbers"), | |
| 726 | .email_addresses = try selector("emailAddresses"), | |
| 727 | ||
| 728 | .string_from_contact = try selector("stringFromContact:style:"), | |
| 729 | ||
| 730 | .label = try selector("label"), | |
| 731 | .value = try selector("value"), | |
| 732 | .localized_string_for_label = try selector("localizedStringForLabel:"), | |
| 733 | ||
| 734 | .string_value = try selector("stringValue"), | |
| 735 | ||
| 736 | .empty_string = try selector("string"), | |
| 737 | .utf8 = try selector("UTF8String"), | |
| 738 | .length_of_bytes = try selector("lengthOfBytesUsingEncoding:"), | |
| 739 | }; | |
| 740 | } | |
| 741 | }; | |
| 742 | ||
| 743 | /// One `NSString` as bytes, or a refusal. | |
| 744 | /// | |
| 745 | /// The truncation check is why this is not one line. `UTF8String` hands back a | |
| 746 | /// NUL-terminated buffer, so a value containing U+0000 — which | |
| 747 | /// `JSONSerialization` would have preserved on the Swift side — reads back as | |
| 748 | /// its prefix. `lengthOfBytesUsingEncoding:` counts the real encoded bytes, so a | |
| 749 | /// mismatch is exactly that case, and reporting a prefix as the whole name is | |
| 750 | /// the one thing worse than failing. | |
| 751 | /// | |
| 752 | /// The returned slice borrows the string's internal buffer, valid for the | |
| 753 | /// current autorelease pool; every caller copies it into the reply before | |
| 754 | /// returning. | |
| 755 | fn readString(ns: Id, sels: Sels) ![]const u8 { | |
| 756 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 757 | if (ns == null) return error.NilNativeString; | |
| 758 | ||
| 759 | const Utf8Fn = *const fn (Id, Id) callconv(.c) ?[*:0]const u8; | |
| 760 | const utf8: Utf8Fn = @ptrCast(&objc.objc_msgSend); | |
| 761 | const cstr = utf8(ns, sels.utf8) orelse return error.NativeCallFailed; | |
| 762 | const text = std.mem.span(cstr); | |
| 763 | ||
| 764 | const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong; | |
| 765 | const len_of: LenFn = @ptrCast(&objc.objc_msgSend); | |
| 766 | if (len_of(ns, sels.length_of_bytes, ns_utf8_string_encoding) != text.len) { | |
| 767 | return error.EmbeddedNulInNativeString; | |
| 768 | } | |
| 769 | ||
| 770 | return text; | |
| 771 | } | |
| 772 | ||
| 773 | /// `[[NSString string]]` — an empty, autoreleased `NSString`. | |
| 774 | /// | |
| 775 | /// Needed because `CNLabeledValue.label` is nullable and Swift passes `""` in | |
| 776 | /// its place (`phone.label ?? ""`) before asking for the localized form. Passing | |
| 777 | /// nil instead would be a different call than the one being ported. | |
| 778 | fn emptyString(sels: Sels) !Id { | |
| 779 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 780 | return objc.msgSendId(sels.string_class, sels.empty_string) orelse error.NativeCallFailed; | |
| 781 | } | |
| 782 | ||
| 783 | /// `+[CNLabeledValue localizedStringForLabel:]` on `labeled.label ?? ""`. | |
| 784 | fn readLocalizedLabel(labeled: Id, sels: Sels) ![]const u8 { | |
| 785 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 786 | ||
| 787 | const raw = objc.msgSendId(labeled, sels.label) orelse try emptyString(sels); | |
| 788 | const localized = objc.msgSendId1(sels.labeled_value_class, sels.localized_string_for_label, raw) orelse | |
| 789 | return error.NativeCallFailed; | |
| 790 | return readString(localized, sels); | |
| 791 | } | |
| 792 | ||
| 793 | /// Which kind of `CNLabeledValue` array is being walked. | |
| 794 | /// | |
| 795 | /// The two differ in exactly one step: a phone's `value` is a `CNPhoneNumber` | |
| 796 | /// and needs `stringValue`, while an email's `value` already *is* an | |
| 797 | /// `NSString`. Sending `stringValue` to an `NSString` would be an unrecognised | |
| 798 | /// selector, which is a SIGABRT rather than an error to map — so this is an | |
| 799 | /// enum rather than an optional selector that could arrive null. | |
| 800 | const LabeledKind = enum { phone, email }; | |
| 801 | ||
| 802 | /// Walk an `NSArray<CNLabeledValue *>` into `Labeled` entries. | |
| 803 | fn readLabeledValues( | |
| 804 | allocator: std.mem.Allocator, | |
| 805 | array: Id, | |
| 806 | sels: Sels, | |
| 807 | comptime kind: LabeledKind, | |
| 808 | ) ![]Labeled { | |
| 809 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 810 | // Both `phoneNumbers` and `emailAddresses` are declared non-null and answer | |
| 811 | // an empty array for a contact with none. A nil here would mean the | |
| 812 | // framework broke its own contract, and "this contact has no numbers" is a | |
| 813 | // claim there would be no basis for. | |
| 814 | if (array == null) return error.NativeCallFailed; | |
| 815 | ||
| 816 | const CountFn = *const fn (Id, Id) callconv(.c) c_ulong; | |
| 817 | const count_of: CountFn = @ptrCast(&objc.objc_msgSend); | |
| 818 | const total = count_of(array, sels.count); | |
| 819 | ||
| 820 | var out: std.ArrayListUnmanaged(Labeled) = .empty; | |
| 821 | errdefer out.deinit(allocator); | |
| 822 | ||
| 823 | var i: c_ulong = 0; | |
| 824 | while (i < total) : (i += 1) { | |
| 825 | const entry = objc.msgSendId1(array, sels.object_at, i) orelse return error.NativeCallFailed; | |
| 826 | ||
| 827 | const label = try readLocalizedLabel(entry, sels); | |
| 828 | ||
| 829 | const raw_value = objc.msgSendId(entry, sels.value) orelse return error.NativeCallFailed; | |
| 830 | const string_value = switch (kind) { | |
| 831 | .phone => objc.msgSendId(raw_value, sels.string_value) orelse | |
| 832 | return error.NativeCallFailed, | |
| 833 | .email => raw_value, | |
| 834 | }; | |
| 835 | ||
| 836 | try out.append(allocator, .{ .label = label, .value = try readString(string_value, sels) }); | |
| 837 | } | |
| 838 | ||
| 839 | return out.toOwnedSlice(allocator); | |
| 840 | } | |
| 841 | ||
| 842 | /// Read one `CNContact` the way `formatContact` reads it. | |
| 843 | /// | |
| 844 | /// `displayName` is the one field where nil is expected and is *not* a failure: | |
| 845 | /// `CNContactFormatter.string(from:style:)` returns `String?` and Swift coalesces | |
| 846 | /// it with `?? ""`. Every other field is declared non-null — `identifier`, | |
| 847 | /// `givenName` and `familyName` answer `@""` for an unset name — so nil there is | |
| 848 | /// a broken read and is refused rather than papered over with an empty string. | |
| 849 | fn readContact(allocator: std.mem.Allocator, contact: Id, sels: Sels) !Contact { | |
| 850 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 851 | if (contact == null) return error.NativeCallFailed; | |
| 852 | ||
| 853 | const FormatFn = *const fn (Id, Id, Id, c_long) callconv(.c) Id; | |
| 854 | const format: FormatFn = @ptrCast(&objc.objc_msgSend); | |
| 855 | const formatted = format( | |
| 856 | sels.formatter_class, | |
| 857 | sels.string_from_contact, | |
| 858 | contact, | |
| 859 | cn_contact_formatter_style_full_name, | |
| 860 | ); | |
| 861 | ||
| 862 | const phones = try readLabeledValues( | |
| 863 | allocator, | |
| 864 | objc.msgSendId(contact, sels.phone_numbers), | |
| 865 | sels, | |
| 866 | .phone, | |
| 867 | ); | |
| 868 | errdefer allocator.free(phones); | |
| 869 | ||
| 870 | const emails = try readLabeledValues( | |
| 871 | allocator, | |
| 872 | objc.msgSendId(contact, sels.email_addresses), | |
| 873 | sels, | |
| 874 | .email, | |
| 875 | ); | |
| 876 | errdefer allocator.free(emails); | |
| 877 | ||
| 878 | return .{ | |
| 879 | .id = try readString(objc.msgSendId(contact, sels.identifier), sels), | |
| 880 | .given_name = try readString(objc.msgSendId(contact, sels.given_name), sels), | |
| 881 | .family_name = try readString(objc.msgSendId(contact, sels.family_name), sels), | |
| 882 | // Swift's `?? ""`, and only here. | |
| 883 | .display_name = if (formatted == null) "" else try readString(formatted, sels), | |
| 884 | .phones = phones, | |
| 885 | .emails = emails, | |
| 886 | }; | |
| 887 | } | |
| 888 | ||
| 889 | /// Release the two slices `readContact` allocated. The strings inside are | |
| 890 | /// borrowed from Objective-C and are not ours to free. | |
| 891 | fn freeContact(allocator: std.mem.Allocator, contact: Contact) void { | |
| 892 | allocator.free(contact.phones); | |
| 893 | allocator.free(contact.emails); | |
| 894 | } | |
| 895 | ||
| 896 | // ============================================================================= | |
| 897 | // The delegate class, built at runtime. | |
| 898 | // ============================================================================= | |
| 899 | ||
| 900 | /// Deliberately distinct from every other class this process registers. The | |
| 901 | /// factory looks a name up before allocating, so two files sharing a name would | |
| 902 | /// mean whichever registered second silently adopts the other's IMPs. | |
| 903 | const delegate_class_name = "CraftIOSContactPickerDelegate"; | |
| 904 | ||
| 905 | /// The delegate instance, held for the life of the process. | |
| 906 | /// | |
| 907 | /// `CNContactPickerViewController` holds its delegate **weakly**; | |
| 908 | /// `ios_delegate.instantiate` hands back a +1 object and does not keep it. This | |
| 909 | /// var is the strong reference, and dropping it would be a use-after-free the | |
| 910 | /// first time the user taps Cancel — not a leak. | |
| 911 | var delegate_instance: Id = null; | |
| 912 | ||
| 913 | /// Every method the delegate class carries, always all of them. | |
| 914 | /// | |
| 915 | /// All three of Swift's `CNContactPickerDelegate` methods are here. | |
| 916 | /// `contactPicker:didSelectContacts:` is the plural selector Swift's | |
| 917 | /// `didSelect contacts: [CNContact]` compiles to and is the entire | |
| 918 | /// `multiple:true` reply path — the runtime dispatches delegate methods through | |
| 919 | /// `respondsToSelector:`, so omitting it would be a silent no-op and a stranded | |
| 920 | /// promise. `presentationControllerDidDismiss:` is the fourth and is not | |
| 921 | /// Swift's: it belongs to `UIAdaptivePresentationControllerDelegate` and is the | |
| 922 | /// swipe-down belt `attachDismissObserver` installs. Neither protocol is | |
| 923 | /// declared on the class, because `respondsToSelector:` is what UIKit actually | |
| 924 | /// asks — the same call `bridge_mobile_imagepicker.zig` records. | |
| 925 | /// | |
| 926 | /// One fixed set rather than a per-call one: `defineClass` is idempotent by | |
| 927 | /// name, so varying the methods would mean inventing a second class. | |
| 928 | fn delegateMethods() [4]ios_delegate.Method { | |
| 929 | return .{ | |
| 930 | // - (void)contactPicker:(CNContactPickerViewController *)picker | |
| 931 | // didSelectContact:(CNContact *)contact | |
| 932 | .{ | |
| 933 | .selector = "contactPicker:didSelectContact:", | |
| 934 | .imp = @ptrCast(&didSelectContact), | |
| 935 | .types = ios_delegate.enc.void_two_objects, | |
| 936 | }, | |
| 937 | // - (void)contactPicker:(CNContactPickerViewController *)picker | |
| 938 | // didSelectContacts:(NSArray<CNContact *> *)contacts | |
| 939 | .{ | |
| 940 | .selector = "contactPicker:didSelectContacts:", | |
| 941 | .imp = @ptrCast(&didSelectContacts), | |
| 942 | .types = ios_delegate.enc.void_two_objects, | |
| 943 | }, | |
| 944 | // - (void)contactPickerDidCancel:(CNContactPickerViewController *)picker | |
| 945 | .{ | |
| 946 | .selector = "contactPickerDidCancel:", | |
| 947 | .imp = @ptrCast(&didCancel), | |
| 948 | .types = ios_delegate.enc.void_one_object, | |
| 949 | }, | |
| 950 | // - (void)presentationControllerDidDismiss:(UIPresentationController *)pc | |
| 951 | // The swipe-down belt. iOS 13+; on anything older it is never sent. | |
| 952 | .{ | |
| 953 | .selector = "presentationControllerDidDismiss:", | |
| 954 | .imp = @ptrCast(&didDismissInteractively), | |
| 955 | .types = ios_delegate.enc.void_one_object, | |
| 956 | }, | |
| 957 | }; | |
| 958 | } | |
| 959 | ||
| 960 | /// Register the class once and keep one instance alive. | |
| 961 | fn ensureDelegate() !Id { | |
| 962 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 963 | if (delegate_instance) |existing| return existing; | |
| 964 | ||
| 965 | const methods = delegateMethods(); | |
| 966 | const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &methods); | |
| 967 | ||
| 968 | const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed; | |
| 969 | delegate_instance = instance; | |
| 970 | return instance; | |
| 971 | } | |
| 972 | ||
| 973 | /// The three selectors the swipe-down belt needs, resolved at dispatch. | |
| 974 | const DismissSels = struct { | |
| 975 | /// `-[UIViewController presentationController]`. | |
| 976 | presentation: Id, | |
| 977 | /// `-[UIPresentationController delegate]`, read so an existing delegate is | |
| 978 | /// never displaced. | |
| 979 | delegate: Id, | |
| 980 | /// `-[UIPresentationController setDelegate:]` — the same `setDelegate:` the | |
| 981 | /// picker's own delegate is installed with. | |
| 982 | set_delegate: Id, | |
| 983 | }; | |
| 984 | ||
| 985 | /// Point the picker's presentation controller at this module's delegate, so an | |
| 986 | /// interactive dismissal reaches `presentationControllerDidDismiss:`. Returns | |
| 987 | /// whether it was attached. | |
| 988 | /// | |
| 989 | /// `CNContactPickerViewController` presents as a sheet on iOS 13+, and a | |
| 990 | /// swipe-down is widely reported not to send `contactPickerDidCancel:`. That | |
| 991 | /// report is verified on no device in this repo, so this is a belt and not a | |
| 992 | /// fix: it costs two messages, and if the report is right it is the only thing | |
| 993 | /// that ever frees `pending` after a swipe. It matters more here than in Swift, | |
| 994 | /// because this module *refuses* later calls while one is pending — a stranded | |
| 995 | /// ticket disables `pickContact` for the life of the process, where Swift | |
| 996 | /// merely strands the one promise. | |
| 997 | /// | |
| 998 | /// A presentation controller that already carries a delegate is left alone and | |
| 999 | /// reported as not attached: displacing one would change how the picker manages | |
| 1000 | /// its own adaptive presentation, which is a new bug in exchange for a belt. | |
| 1001 | /// | |
| 1002 | /// Idempotent against the real cancel. UIKit sends | |
| 1003 | /// `presentationControllerDidDismiss:` only for a user-driven dismissal, never | |
| 1004 | /// for a programmatic one, so it cannot follow a pick or a Cancel that already | |
| 1005 | /// answered — and if it did anyway, `takePending` has already emptied the slot | |
| 1006 | /// and `ios_async`'s generation counter would drop the second delivery. | |
| 1007 | fn attachDismissObserver(picker: Id, delegate: Id, sels: DismissSels) bool { | |
| 1008 | if (!is_darwin) return false; | |
| 1009 | ||
| 1010 | const presentation = objc.msgSendId(picker, sels.presentation) orelse return false; | |
| 1011 | if (objc.msgSendId(presentation, sels.delegate) != null) return false; | |
| 1012 | ||
| 1013 | objc.msgSendVoid1(presentation, sels.set_delegate, delegate); | |
| 1014 | return true; | |
| 1015 | } | |
| 1016 | ||
| 1017 | // ============================================================================= | |
| 1018 | // One ticket, one picker, one mutex. | |
| 1019 | // | |
| 1020 | // A picker is modal and serves exactly one call, so unlike the location | |
| 1021 | // one-shot there is never a reason to displace: while `pending` is set the | |
| 1022 | // picker is on screen and the user is looking at it. The mutex is held even | |
| 1023 | // though UIKit delivers these callbacks on the main thread, because "it should | |
| 1024 | // always be the main thread" is not a guard. | |
| 1025 | // ============================================================================= | |
| 1026 | ||
| 1027 | const Pending = struct { | |
| 1028 | ticket: ios_async.Ticket, | |
| 1029 | sels: Sels, | |
| 1030 | }; | |
| 1031 | ||
| 1032 | var pending: ?Pending = null; | |
| 1033 | var pending_mutex: compat_mutex.Mutex = .{}; | |
| 1034 | ||
| 1035 | /// Record the call the delegate will answer. False means one is already | |
| 1036 | /// recorded, and the caller must refuse rather than overwrite — an overwrite | |
| 1037 | /// would strand a promise that was about to be settled. | |
| 1038 | fn publishPending(call: Pending) bool { | |
| 1039 | pending_mutex.lock(); | |
| 1040 | defer pending_mutex.unlock(); | |
| 1041 | if (pending != null) return false; | |
| 1042 | pending = call; | |
| 1043 | return true; | |
| 1044 | } | |
| 1045 | ||
| 1046 | /// Read and clear. Clearing is what makes a second delegate call — a duplicate | |
| 1047 | /// fire, a cancel that races a selection — a no-op rather than a second reply | |
| 1048 | /// to a promise that has already settled. | |
| 1049 | fn takePending() ?Pending { | |
| 1050 | pending_mutex.lock(); | |
| 1051 | defer pending_mutex.unlock(); | |
| 1052 | const call = pending; | |
| 1053 | pending = null; | |
| 1054 | return call; | |
| 1055 | } | |
| 1056 | ||
| 1057 | // ============================================================================= | |
| 1058 | // The delegate methods. | |
| 1059 | // | |
| 1060 | // None replies directly: `evaluateJavaScript` is main-thread-only *and* the | |
| 1061 | // `request_context` that names the waiting call is long gone, so the finished | |
| 1062 | // JSON goes to `ios_async.deliverJson` (or `deliverError`), which hops to the | |
| 1063 | // main queue and answers under the id captured back at dispatch. | |
| 1064 | // | |
| 1065 | // Plain `fn`, never `export`: `@ptrCast(&f)` works either way and an exported | |
| 1066 | // name could collide with a desktop module in the same host-test binary. | |
| 1067 | // | |
| 1068 | // No `dismissViewControllerAnimated:` in any of them. See the module comment — | |
| 1069 | // `CNContactPickerViewController` is a remote view controller that takes itself | |
| 1070 | // down, Swift dismisses it nowhere, and a second dismissal would target | |
| 1071 | // whatever else is modal. `didDismissInteractively` observes a dismissal the | |
| 1072 | // user performed; it never asks for one. | |
| 1073 | // ============================================================================= | |
| 1074 | ||
| 1075 | fn didSelectContact(_: Id, _: Id, _: Id, contact: Id) callconv(.c) void { | |
| 1076 | if (!is_darwin) return; | |
| 1077 | ||
| 1078 | const call = takePending() orelse { | |
| 1079 | std.log.warn( | |
| 1080 | "pickContact: a contact was selected with no request waiting; ignored rather than " ++ | |
| 1081 | "answered to whoever holds the slot next", | |
| 1082 | .{}, | |
| 1083 | ); | |
| 1084 | return; | |
| 1085 | }; | |
| 1086 | ||
| 1087 | const allocator = std.heap.c_allocator; | |
| 1088 | ||
| 1089 | const one = readContact(allocator, contact, call.sels) catch |err| { | |
| 1090 | std.log.err( | |
| 1091 | "pickContact: could not read the selected CNContact ({}); rejecting rather than " ++ | |
| 1092 | "replying with a contact that was not read", | |
| 1093 | .{err}, | |
| 1094 | ); | |
| 1095 | ios_async.deliverError(call.ticket); | |
| 1096 | return; | |
| 1097 | }; | |
| 1098 | defer freeContact(allocator, one); | |
| 1099 | ||
| 1100 | const json = shapeContact(allocator, one) catch |err| { | |
| 1101 | std.log.err("pickContact: could not shape the contact reply ({}); rejecting", .{err}); | |
| 1102 | ios_async.deliverError(call.ticket); | |
| 1103 | return; | |
| 1104 | }; | |
| 1105 | defer allocator.free(json); | |
| 1106 | ||
| 1107 | ios_async.deliverJson(call.ticket, json); | |
| 1108 | } | |
| 1109 | ||
| 1110 | fn didSelectContacts(_: Id, _: Id, _: Id, contacts: Id) callconv(.c) void { | |
| 1111 | if (!is_darwin) return; | |
| 1112 | ||
| 1113 | const call = takePending() orelse { | |
| 1114 | std.log.warn( | |
| 1115 | "pickContact: contacts were selected with no request waiting; ignored rather than " ++ | |
| 1116 | "answered to whoever holds the slot next", | |
| 1117 | .{}, | |
| 1118 | ); | |
| 1119 | return; | |
| 1120 | }; | |
| 1121 | ||
| 1122 | const allocator = std.heap.c_allocator; | |
| 1123 | ||
| 1124 | const list = readContacts(allocator, contacts, call.sels) catch |err| { | |
| 1125 | std.log.err( | |
| 1126 | "pickContact: could not read the selected CNContacts ({}); rejecting rather than " ++ | |
| 1127 | "replying with a selection that was not read", | |
| 1128 | .{err}, | |
| 1129 | ); | |
| 1130 | ios_async.deliverError(call.ticket); | |
| 1131 | return; | |
| 1132 | }; | |
| 1133 | defer { | |
| 1134 | for (list) |one| freeContact(allocator, one); | |
| 1135 | allocator.free(list); | |
| 1136 | } | |
| 1137 | ||
| 1138 | const json = shapeContacts(allocator, list) catch |err| { | |
| 1139 | std.log.err("pickContact: could not shape the contacts reply ({}); rejecting", .{err}); | |
| 1140 | ios_async.deliverError(call.ticket); | |
| 1141 | return; | |
| 1142 | }; | |
| 1143 | defer allocator.free(json); | |
| 1144 | ||
| 1145 | // An empty selection is `[]` and resolves — see `shapeContacts`. | |
| 1146 | ios_async.deliverJson(call.ticket, json); | |
| 1147 | } | |
| 1148 | ||
| 1149 | /// Walk `NSArray<CNContact *>`, reading each contact. | |
| 1150 | /// | |
| 1151 | /// A nil array is refused rather than answered `[]`: the delegate parameter is | |
| 1152 | /// `[CNContact]`, non-optional, so nil would mean the framework broke its own | |
| 1153 | /// contract and "the user selected nothing" is a claim with no basis. | |
| 1154 | fn readContacts(allocator: std.mem.Allocator, contacts: Id, sels: Sels) ![]Contact { | |
| 1155 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1156 | if (contacts == null) return error.NativeCallFailed; | |
| 1157 | ||
| 1158 | const CountFn = *const fn (Id, Id) callconv(.c) c_ulong; | |
| 1159 | const count_of: CountFn = @ptrCast(&objc.objc_msgSend); | |
| 1160 | const total = count_of(contacts, sels.count); | |
| 1161 | ||
| 1162 | var out: std.ArrayListUnmanaged(Contact) = .empty; | |
| 1163 | errdefer { | |
| 1164 | for (out.items) |one| freeContact(allocator, one); | |
| 1165 | out.deinit(allocator); | |
| 1166 | } | |
| 1167 | ||
| 1168 | var i: c_ulong = 0; | |
| 1169 | while (i < total) : (i += 1) { | |
| 1170 | const contact = objc.msgSendId1(contacts, sels.object_at, i) orelse | |
| 1171 | return error.NativeCallFailed; | |
| 1172 | try out.append(allocator, try readContact(allocator, contact, sels)); | |
| 1173 | } | |
| 1174 | ||
| 1175 | return out.toOwnedSlice(allocator); | |
| 1176 | } | |
| 1177 | ||
| 1178 | /// The user tapped Cancel. | |
| 1179 | /// | |
| 1180 | /// A **rejection**, matching `rejectCallback(pendingCallbackId, error: "Cancelled")` | |
| 1181 | /// — never a resolve. Replying `{"cancelled":true}` here would be a success for | |
| 1182 | /// a pick that did not happen, and the page's `catch` (which is where | |
| 1183 | /// `test-bridges.html` prints the outcome) would never run. | |
| 1184 | /// | |
| 1185 | /// The code and message the page sees are `NATIVE_CALL_FAILED` / | |
| 1186 | /// `"Native API call failed"` rather than Swift's `CRAFT_ERROR` / `"Cancelled"`. | |
| 1187 | /// That is `ios_async.deliverError`'s fixed wording and it is genuinely wrong | |
| 1188 | /// about the cause; the module comment records what it would take to fix, and | |
| 1189 | /// why inventing a second reply path here would be worse. | |
| 1190 | fn didCancel(_: Id, _: Id, _: Id) callconv(.c) void { | |
| 1191 | if (!is_darwin) return; | |
| 1192 | ||
| 1193 | const call = takePending() orelse { | |
| 1194 | std.log.info("pickContact: a cancel arrived with no request waiting; ignored", .{}); | |
| 1195 | return; | |
| 1196 | }; | |
| 1197 | ||
| 1198 | std.log.info( | |
| 1199 | "pickContact: the user cancelled; rejecting the call (as CRAFT_ERROR/\"Cancelled\" in " ++ | |
| 1200 | "Swift, as NATIVE_CALL_FAILED here)", | |
| 1201 | .{}, | |
| 1202 | ); | |
| 1203 | // A Cancel is not a native failure: the call worked and the answer is | |
| 1204 | // "no". Reporting NativeCallFailed sends whoever reads the error looking | |
| 1205 | // for a bug that is not there. | |
| 1206 | ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Cancelled); | |
| 1207 | } | |
| 1208 | ||
| 1209 | /// The user swiped the picker away. | |
| 1210 | /// | |
| 1211 | /// `-presentationControllerDidDismiss:`, and the only thing that answers a | |
| 1212 | /// dismissal `contactPickerDidCancel:` is reported not to follow. A | |
| 1213 | /// **rejection**, for the same reason a Cancel is one: nothing was picked, so | |
| 1214 | /// resolving anything here would be a success for a pick that did not happen. | |
| 1215 | /// | |
| 1216 | /// Reached with the slot already empty whenever a real callback got there first | |
| 1217 | /// — a Cancel that did send, or a pick — in which case this is a no-op rather | |
| 1218 | /// than a second answer. | |
| 1219 | /// | |
| 1220 | /// Same `NATIVE_CALL_FAILED` / `"Native API call failed"` wording as `didCancel` | |
| 1221 | /// and wrong about the cause in the same way; see the module comment for where | |
| 1222 | /// that is fixed. | |
| 1223 | fn didDismissInteractively(_: Id, _: Id, _: Id) callconv(.c) void { | |
| 1224 | if (!is_darwin) return; | |
| 1225 | ||
| 1226 | const call = takePending() orelse { | |
| 1227 | std.log.info( | |
| 1228 | "pickContact: the picker was dismissed with no request waiting; ignored " ++ | |
| 1229 | "(the cancel or the pick already answered)", | |
| 1230 | .{}, | |
| 1231 | ); | |
| 1232 | return; | |
| 1233 | }; | |
| 1234 | ||
| 1235 | std.log.warn( | |
| 1236 | "pickContact: the picker was dismissed interactively without sending " ++ | |
| 1237 | "contactPickerDidCancel:; rejecting the call rather than leaving it unanswered " ++ | |
| 1238 | "on a promise that has no timeout", | |
| 1239 | .{}, | |
| 1240 | ); | |
| 1241 | ios_async.deliverError(call.ticket); | |
| 1242 | } | |
| 1243 | ||
| 1244 | // ============================================================================= | |
| 1245 | // Tests — host-only. | |
| 1246 | // | |
| 1247 | // Everything that decides page-visible bytes is pure and pinned here: routing | |
| 1248 | // in both directions, the `as? Bool ?? false` coercion, the six keys and their | |
| 1249 | // order, the two different value keys for phones and emails, the empty-array | |
| 1250 | // resolve, escaping, and the concurrency rule. | |
| 1251 | // | |
| 1252 | // Nothing here constructs a `CNContactPickerViewController` or presents | |
| 1253 | // anything. On a macOS host that class does not exist at all (macOS has | |
| 1254 | // `CNContactPicker`), and presenting from a test process is not something a | |
| 1255 | // suite should be able to do by accident. | |
| 1256 | // ============================================================================= | |
| 1257 | ||
| 1258 | const testing = std.testing; | |
| 1259 | ||
| 1260 | /// The pending slot is module state, and a test that leaves it set would make | |
| 1261 | /// the next one refuse. Every test that touches it clears it through here. | |
| 1262 | fn resetPendingForTesting() void { | |
| 1263 | pending_mutex.lock(); | |
| 1264 | defer pending_mutex.unlock(); | |
| 1265 | pending = null; | |
| 1266 | } | |
| 1267 | ||
| 1268 | fn fakeTicket(index: u5, generation: u32) ios_async.Ticket { | |
| 1269 | return .{ .index = index, .generation = generation }; | |
| 1270 | } | |
| 1271 | ||
| 1272 | fn fakeSels() Sels { | |
| 1273 | // Every field is a plain `Id`; nothing below dereferences them, and the | |
| 1274 | // concurrency rule under test does not care what they point at. | |
| 1275 | return std.mem.zeroes(Sels); | |
| 1276 | } | |
| 1277 | ||
| 1278 | test "the declared action is the one the handler serves" { | |
| 1279 | try testing.expectEqual(@as(usize, 1), capability_actions.len); | |
| 1280 | try testing.expectEqualStrings(A.pick_contact, capability_actions[0].name); | |
| 1281 | ||
| 1282 | for (capability_actions) |decl| { | |
| 1283 | // A `.result` whose handler never replies parks the caller on a promise | |
| 1284 | // with no timeout; a `.none` that is awaited resolves immediately and | |
| 1285 | // means nothing. Swift resolves or rejects every path, so `.result`. | |
| 1286 | try testing.expectEqual(capabilities.Reply.result, decl.reply); | |
| 1287 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); | |
| 1288 | // `.live` with a reason would be a contradiction the manifest shows apps. | |
| 1289 | try testing.expect(decl.reason == null); | |
| 1290 | } | |
| 1291 | } | |
| 1292 | ||
| 1293 | test "the action name matches the Swift case label exactly" { | |
| 1294 | // The conformance ratchet compares this against the `case "…":` labels in | |
| 1295 | // `CraftApp.swift` in both directions; a prettier spelling would read as Zig | |
| 1296 | // serving an action the spec does not have. | |
| 1297 | try testing.expectEqualStrings("pickContact", A.pick_contact); | |
| 1298 | } | |
| 1299 | ||
| 1300 | test "every declared action is one the dispatcher routes" { | |
| 1301 | for (capability_actions) |decl| { | |
| 1302 | if (routeFor(decl.name) == null) { | |
| 1303 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 1304 | return error.DeclaredActionDoesNotRoute; | |
| 1305 | } | |
| 1306 | } | |
| 1307 | } | |
| 1308 | ||
| 1309 | test "every route the dispatcher has is a declared action" { | |
| 1310 | // The other direction: a route with a handler and no declaration is an | |
| 1311 | // action the page can call and the manifest denies exists. Each declaration | |
| 1312 | // must claim a *distinct* route — counting alone would let two rows share | |
| 1313 | // one route while another went undeclared. | |
| 1314 | var claimed = std.mem.zeroes([std.enums.values(Route).len]bool); | |
| 1315 | for (capability_actions) |decl| { | |
| 1316 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 1317 | const slot = @backingInt(route); | |
| 1318 | if (claimed[slot]) { | |
| 1319 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 1320 | return error.TwoDeclarationsShareARoute; | |
| 1321 | } | |
| 1322 | claimed[slot] = true; | |
| 1323 | } | |
| 1324 | for (claimed, 0..) |taken, slot| { | |
| 1325 | if (!taken) { | |
| 1326 | std.debug.print( | |
| 1327 | "route {s} has a handler but no capability_actions row\n", | |
| 1328 | .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))}, | |
| 1329 | ); | |
| 1330 | return error.RouteNotDeclared; | |
| 1331 | } | |
| 1332 | } | |
| 1333 | } | |
| 1334 | ||
| 1335 | test "an action this module does not serve is refused as UnknownAction" { | |
| 1336 | // Not any other error: `ios_dispatch.route` reads UnknownAction as "not | |
| 1337 | // mine, ask the next module" and anything else as a final answer. Getting | |
| 1338 | // this wrong would make this file swallow another module's action — or the | |
| 1339 | // shim's. | |
| 1340 | var bridge = ContactPickerBridge.init(testing.allocator); | |
| 1341 | defer bridge.deinit(); | |
| 1342 | ||
| 1343 | for ([_][]const u8{ | |
| 1344 | "pickImage", | |
| 1345 | "pickDocument", | |
| 1346 | "getContacts", | |
| 1347 | "addContact", | |
| 1348 | // Casing is how a real typo arrives, and a miss does not fail loudly: | |
| 1349 | // the action would quietly fall through to the Swift shim. | |
| 1350 | "pickcontact", | |
| 1351 | "PickContact", | |
| 1352 | "pickContacts", | |
| 1353 | "", | |
| 1354 | }) |action| { | |
| 1355 | try testing.expectError( | |
| 1356 | bridge_error.BridgeError.UnknownAction, | |
| 1357 | bridge.handleMessage(action, "{}"), | |
| 1358 | ); | |
| 1359 | try testing.expect(routeFor(action) == null); | |
| 1360 | } | |
| 1361 | } | |
| 1362 | ||
| 1363 | test "pickContact routes to its own handler" { | |
| 1364 | try testing.expectEqual(Route.pick_contact, routeFor("pickContact").?); | |
| 1365 | } | |
| 1366 | ||
| 1367 | // --------------------------------------------------------------------------- | |
| 1368 | // Payload parsing | |
| 1369 | // --------------------------------------------------------------------------- | |
| 1370 | ||
| 1371 | test "an absent multiple is false, exactly as Swift's ?? false is" { | |
| 1372 | // `ios_dispatch.payloadOf` hands an absent `d` through as `{}`, which is the | |
| 1373 | // first case here. The page always sends the field today, but a body without | |
| 1374 | // it must not become a multi-select picker. | |
| 1375 | try testing.expect(!try parseMultiple(testing.allocator, "{}")); | |
| 1376 | try testing.expect(!try parseMultiple(testing.allocator, "{\"other\":1}")); | |
| 1377 | } | |
| 1378 | ||
| 1379 | test "a boolean multiple is carried through, both ways" { | |
| 1380 | try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":true}")); | |
| 1381 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":false}")); | |
| 1382 | } | |
| 1383 | ||
| 1384 | test "the numbers NSNumber-as-Bool accepts are the only numbers accepted" { | |
| 1385 | // `options.multiple || false` is a fallback, not a coercion, so | |
| 1386 | // `pickContact({multiple: 1})` really does put `1` on the wire. Swift's | |
| 1387 | // `as? Bool` succeeds for 0 and 1 and fails for everything else. | |
| 1388 | try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":1}")); | |
| 1389 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0}")); | |
| 1390 | try testing.expect(try parseMultiple(testing.allocator, "{\"multiple\":1.0}")); | |
| 1391 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0.0}")); | |
| 1392 | ||
| 1393 | // 2 is truthy in JavaScript and still opens a single-selection picker, | |
| 1394 | // because `as? Bool` returns nil for it. This is the case a "truthy means | |
| 1395 | // multiple" reading would get wrong. | |
| 1396 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":2}")); | |
| 1397 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":-1}")); | |
| 1398 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":0.5}")); | |
| 1399 | } | |
| 1400 | ||
| 1401 | test "a non-numeric truthy multiple is false, not multi" { | |
| 1402 | // `pickContact({multiple: 'yes'})` puts the string on the wire and Swift | |
| 1403 | // reads it as false. | |
| 1404 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":\"yes\"}")); | |
| 1405 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":\"true\"}")); | |
| 1406 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":[]}")); | |
| 1407 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":{}}")); | |
| 1408 | // `JSON.stringify` keeps an explicit null where it drops an undefined. | |
| 1409 | try testing.expect(!try parseMultiple(testing.allocator, "{\"multiple\":null}")); | |
| 1410 | } | |
| 1411 | ||
| 1412 | test "a body that cannot be read is an error, not a defaulted false" { | |
| 1413 | // Acting on a default for a payload that failed to parse is how a field the | |
| 1414 | // page sent goes missing with success reported anyway. | |
| 1415 | try testing.expectError( | |
| 1416 | bridge_error.BridgeError.InvalidJSON, | |
| 1417 | parseMultiple(testing.allocator, "{\"multiple\":"), | |
| 1418 | ); | |
| 1419 | try testing.expectError( | |
| 1420 | bridge_error.BridgeError.InvalidJSON, | |
| 1421 | parseMultiple(testing.allocator, "[1,2]"), | |
| 1422 | ); | |
| 1423 | try testing.expectError( | |
| 1424 | bridge_error.BridgeError.InvalidJSON, | |
| 1425 | parseMultiple(testing.allocator, "\"multiple\""), | |
| 1426 | ); | |
| 1427 | } | |
| 1428 | ||
| 1429 | // --------------------------------------------------------------------------- | |
| 1430 | // Reply shaping | |
| 1431 | // --------------------------------------------------------------------------- | |
| 1432 | ||
| 1433 | test "one contact is the six-key object formatContact builds, in a fixed order" { | |
| 1434 | const allocator = testing.allocator; | |
| 1435 | const json = try shapeContact(allocator, .{ | |
| 1436 | .id = "AB12-CD34", | |
| 1437 | .given_name = "Ada", | |
| 1438 | .family_name = "Lovelace", | |
| 1439 | .display_name = "Ada Lovelace", | |
| 1440 | .phones = &.{.{ .label = "mobile", .value = "+1 555 0100" }}, | |
| 1441 | .emails = &.{.{ .label = "work", .value = "ada@example.com" }}, | |
| 1442 | }); | |
| 1443 | defer allocator.free(json); | |
| 1444 | ||
| 1445 | try testing.expectEqualStrings( | |
| 1446 | "{\"id\":\"AB12-CD34\",\"givenName\":\"Ada\",\"familyName\":\"Lovelace\"," ++ | |
| 1447 | "\"displayName\":\"Ada Lovelace\"," ++ | |
| 1448 | "\"phoneNumbers\":[{\"label\":\"mobile\",\"number\":\"+1 555 0100\"}]," ++ | |
| 1449 | "\"emailAddresses\":[{\"label\":\"work\",\"address\":\"ada@example.com\"}]}", | |
| 1450 | json, | |
| 1451 | ); | |
| 1452 | } | |
| 1453 | ||
| 1454 | test "phones say number and emails say address" { | |
| 1455 | // The single easiest thing to get backwards, and it is silent: both are | |
| 1456 | // `{label, <string>}` and a swapped key still parses. `formatContact` is the | |
| 1457 | // only authority, and `craft.d.ts` — which declares both as `string[]` — is | |
| 1458 | // wrong for this action and must not be followed. | |
| 1459 | const allocator = testing.allocator; | |
| 1460 | const json = try shapeContact(allocator, .{ | |
| 1461 | .id = "1", | |
| 1462 | .given_name = "", | |
| 1463 | .family_name = "", | |
| 1464 | .display_name = "", | |
| 1465 | .phones = &.{.{ .label = "home", .value = "555" }}, | |
| 1466 | .emails = &.{.{ .label = "home", .value = "a@b.c" }}, | |
| 1467 | }); | |
| 1468 | defer allocator.free(json); | |
| 1469 | ||
| 1470 | const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); | |
| 1471 | defer parsed.deinit(); | |
| 1472 | ||
| 1473 | const phone = parsed.value.object.get("phoneNumbers").?.array.items[0].object; | |
| 1474 | try testing.expectEqualStrings("555", phone.get("number").?.string); | |
| 1475 | try testing.expect(phone.get("address") == null); | |
| 1476 | ||
| 1477 | const email = parsed.value.object.get("emailAddresses").?.array.items[0].object; | |
| 1478 | try testing.expectEqualStrings("a@b.c", email.get("address").?.string); | |
| 1479 | try testing.expect(email.get("number") == null); | |
| 1480 | } | |
| 1481 | ||
| 1482 | test "a contact with no numbers and no addresses keeps both keys as empty arrays" { | |
| 1483 | // `formatContact` always assigns both keys, so a page reading | |
| 1484 | // `contact.phoneNumbers.length` must not meet `undefined`. | |
| 1485 | const allocator = testing.allocator; | |
| 1486 | const json = try shapeContact(allocator, .{ | |
| 1487 | .id = "1", | |
| 1488 | .given_name = "Solo", | |
| 1489 | .family_name = "", | |
| 1490 | .display_name = "Solo", | |
| 1491 | .phones = &.{}, | |
| 1492 | .emails = &.{}, | |
| 1493 | }); | |
| 1494 | defer allocator.free(json); | |
| 1495 | ||
| 1496 | try testing.expect(std.mem.indexOf(u8, json, "\"phoneNumbers\":[]") != null); | |
| 1497 | try testing.expect(std.mem.indexOf(u8, json, "\"emailAddresses\":[]") != null); | |
| 1498 | } | |
| 1499 | ||
| 1500 | test "a name that needs escaping stays valid JSON" { | |
| 1501 | // A contact name is user data. Before escaping, a single `"` in it would | |
| 1502 | // close the string literal inside the `evaluateJavaScript:` source and turn | |
| 1503 | // the reply into a syntax error in the page rather than a wrong field. | |
| 1504 | const allocator = testing.allocator; | |
| 1505 | const json = try shapeContact(allocator, .{ | |
| 1506 | .id = "x\\y", | |
| 1507 | .given_name = "Ann \"Annie\"", | |
| 1508 | .family_name = "O'Neil\nJr", | |
| 1509 | .display_name = "Ann\t\"Annie\" O'Neil", | |
| 1510 | .phones = &.{.{ .label = "we\"ird", .value = "+1\\555" }}, | |
| 1511 | .emails = &.{.{ .label = "a\nb", .value = "q\"@example.com" }}, | |
| 1512 | }); | |
| 1513 | defer allocator.free(json); | |
| 1514 | ||
| 1515 | const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); | |
| 1516 | defer parsed.deinit(); | |
| 1517 | ||
| 1518 | const obj = parsed.value.object; | |
| 1519 | try testing.expectEqualStrings("x\\y", obj.get("id").?.string); | |
| 1520 | try testing.expectEqualStrings("Ann \"Annie\"", obj.get("givenName").?.string); | |
| 1521 | try testing.expectEqualStrings("O'Neil\nJr", obj.get("familyName").?.string); | |
| 1522 | try testing.expectEqualStrings( | |
| 1523 | "we\"ird", | |
| 1524 | obj.get("phoneNumbers").?.array.items[0].object.get("label").?.string, | |
| 1525 | ); | |
| 1526 | try testing.expectEqualStrings( | |
| 1527 | "q\"@example.com", | |
| 1528 | obj.get("emailAddresses").?.array.items[0].object.get("address").?.string, | |
| 1529 | ); | |
| 1530 | } | |
| 1531 | ||
| 1532 | test "the multiple reply is an array, not an object" { | |
| 1533 | // `test-bridges.html:1030` reads `contacts?.length`, so an object here would | |
| 1534 | // print `undefined contacts` for a selection that worked. | |
| 1535 | const allocator = testing.allocator; | |
| 1536 | const json = try shapeContacts(allocator, &.{ | |
| 1537 | .{ | |
| 1538 | .id = "1", | |
| 1539 | .given_name = "A", | |
| 1540 | .family_name = "One", | |
| 1541 | .display_name = "A One", | |
| 1542 | .phones = &.{}, | |
| 1543 | .emails = &.{}, | |
| 1544 | }, | |
| 1545 | .{ | |
| 1546 | .id = "2", | |
| 1547 | .given_name = "B", | |
| 1548 | .family_name = "Two", | |
| 1549 | .display_name = "B Two", | |
| 1550 | .phones = &.{.{ .label = "mobile", .value = "555" }}, | |
| 1551 | .emails = &.{}, | |
| 1552 | }, | |
| 1553 | }); | |
| 1554 | defer allocator.free(json); | |
| 1555 | ||
| 1556 | try testing.expect(json[0] == '['); | |
| 1557 | ||
| 1558 | const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); | |
| 1559 | defer parsed.deinit(); | |
| 1560 | ||
| 1561 | try testing.expectEqual(@as(usize, 2), parsed.value.array.items.len); | |
| 1562 | try testing.expectEqualStrings("1", parsed.value.array.items[0].object.get("id").?.string); | |
| 1563 | try testing.expectEqualStrings( | |
| 1564 | "555", | |
| 1565 | parsed.value.array.items[1].object.get("phoneNumbers").?.array.items[0].object.get("number").?.string, | |
| 1566 | ); | |
| 1567 | } | |
| 1568 | ||
| 1569 | test "an empty multi-selection is an empty array that resolves" { | |
| 1570 | // `contacts.map` over `[]` is `[]` and Swift resolves it. Turning this into | |
| 1571 | // an error, or into a cancel, would fail a call the shim answers. `[]` is | |
| 1572 | // also truthy, so `craft-bridge.js`'s `payload || {}` leaves it intact. | |
| 1573 | const allocator = testing.allocator; | |
| 1574 | const json = try shapeContacts(allocator, &.{}); | |
| 1575 | defer allocator.free(json); | |
| 1576 | ||
| 1577 | try testing.expectEqualStrings("[]", json); | |
| 1578 | ||
| 1579 | const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{}); | |
| 1580 | defer parsed.deinit(); | |
| 1581 | try testing.expectEqual(@as(usize, 0), parsed.value.array.items.len); | |
| 1582 | } | |
| 1583 | ||
| 1584 | test "the single reply is an object rather than an array of one" { | |
| 1585 | // The two `didSelect` overloads resolve genuinely different shapes, and a | |
| 1586 | // page doing `contact.givenName` after a single pick would read `undefined` | |
| 1587 | // off a one-element array. | |
| 1588 | const allocator = testing.allocator; | |
| 1589 | const one = Contact{ | |
| 1590 | .id = "1", | |
| 1591 | .given_name = "A", | |
| 1592 | .family_name = "One", | |
| 1593 | .display_name = "A One", | |
| 1594 | .phones = &.{}, | |
| 1595 | .emails = &.{}, | |
| 1596 | }; | |
| 1597 | ||
| 1598 | const single = try shapeContact(allocator, one); | |
| 1599 | defer allocator.free(single); | |
| 1600 | const many = try shapeContacts(allocator, &.{one}); | |
| 1601 | defer allocator.free(many); | |
| 1602 | ||
| 1603 | try testing.expect(single[0] == '{'); | |
| 1604 | try testing.expect(many[0] == '['); | |
| 1605 | ||
| 1606 | const parsed = try std.json.parseFromSlice(std.json.Value, allocator, single, .{}); | |
| 1607 | defer parsed.deinit(); | |
| 1608 | try testing.expectEqualStrings("A", parsed.value.object.get("givenName").?.string); | |
| 1609 | } | |
| 1610 | ||
| 1611 | test "a cancel is a rejection, and these are the exact bytes it puts on the wire" { | |
| 1612 | // `didCancel` calls `ios_async.deliverError`, which lands in | |
| 1613 | // `deliverOnMain` -> `sendErrorToJS(action, NativeCallFailed)`. This builds | |
| 1614 | // the same envelope that path builds, so the page-visible result of a | |
| 1615 | // cancel is pinned rather than described. | |
| 1616 | // | |
| 1617 | // Two things are being asserted at once. First, `"error":true` — it goes | |
| 1618 | // out through `__craftBridgeError`, which *rejects*. A | |
| 1619 | // `{"cancelled":true}` resolve (the shape the image and document pickers | |
| 1620 | // use) would be a fabricated success for a pick that did not happen, and | |
| 1621 | // the page's `catch` — where `test-bridges.html:1022` prints the outcome — | |
| 1622 | // would never run. | |
| 1623 | // | |
| 1624 | // Second, the divergence, stated in bytes: Swift sends | |
| 1625 | // `code:"CRAFT_ERROR"`, `message:"Cancelled"`. These are what the page gets | |
| 1626 | // instead, and they are wrong about the cause. See the module comment for | |
| 1627 | // why the fix belongs in `ios_async.zig` rather than in a second reply path | |
| 1628 | // built here. | |
| 1629 | const allocator = testing.allocator; | |
| 1630 | var ctx = bridge_error.ErrorContext.init( | |
| 1631 | bridge_error.BridgeError.NativeCallFailed, | |
| 1632 | A.pick_contact, | |
| 1633 | bridge_error.errorMessage(bridge_error.BridgeError.NativeCallFailed), | |
| 1634 | ); | |
| 1635 | ctx.request_id = 11; | |
| 1636 | ||
| 1637 | const json = try ctx.toJSON(allocator); | |
| 1638 | defer allocator.free(json); | |
| 1639 | ||
| 1640 | try testing.expectEqualStrings( | |
| 1641 | "{\"error\":true,\"code\":\"NATIVE_CALL_FAILED\",\"action\":\"pickContact\"," ++ | |
| 1642 | "\"message\":\"Native API call failed\",\"id\":11}", | |
| 1643 | json, | |
| 1644 | ); | |
| 1645 | ||
| 1646 | // And it is not any of the resolve shapes: a rejection carries no contact. | |
| 1647 | try testing.expect(std.mem.indexOf(u8, json, "givenName") == null); | |
| 1648 | } | |
| 1649 | ||
| 1650 | // --------------------------------------------------------------------------- | |
| 1651 | // Concurrency | |
| 1652 | // --------------------------------------------------------------------------- | |
| 1653 | ||
| 1654 | test "a second request while one is presented is refused, and the first is left alone" { | |
| 1655 | resetPendingForTesting(); | |
| 1656 | defer resetPendingForTesting(); | |
| 1657 | ||
| 1658 | const first = fakeTicket(3, 7); | |
| 1659 | try testing.expect(publishPending(.{ .ticket = first, .sels = fakeSels() })); | |
| 1660 | ||
| 1661 | // The refusal. Swift overwrites `pendingCallbackId` here and the first | |
| 1662 | // promise never settles; the picker on screen is about to answer, so the | |
| 1663 | // newcomer is the one that has to be told no. | |
| 1664 | try testing.expect(!publishPending(.{ .ticket = fakeTicket(4, 9), .sels = fakeSels() })); | |
| 1665 | ||
| 1666 | // And the slot still holds the *first* ticket, not the newcomer's — a | |
| 1667 | // refusal that quietly replaced would look identical from the return value. | |
| 1668 | pending_mutex.lock(); | |
| 1669 | defer pending_mutex.unlock(); | |
| 1670 | try testing.expectEqual(first.index, pending.?.ticket.index); | |
| 1671 | try testing.expectEqual(first.generation, pending.?.ticket.generation); | |
| 1672 | } | |
| 1673 | ||
| 1674 | test "the slot is handed over once, so a double-firing delegate cannot reply twice" { | |
| 1675 | resetPendingForTesting(); | |
| 1676 | defer resetPendingForTesting(); | |
| 1677 | ||
| 1678 | const ticket = fakeTicket(1, 2); | |
| 1679 | try testing.expect(publishPending(.{ .ticket = ticket, .sels = fakeSels() })); | |
| 1680 | ||
| 1681 | const taken = takePending() orelse return error.PendingWentMissing; | |
| 1682 | try testing.expectEqual(ticket.index, taken.ticket.index); | |
| 1683 | ||
| 1684 | // A cancel racing a selection, or a duplicate fire. `ios_async` would ignore | |
| 1685 | // the stale generation anyway, but the slot has to be empty here so the | |
| 1686 | // callback never even reaches for a ticket that is already spent. | |
| 1687 | try testing.expect(takePending() == null); | |
| 1688 | } | |
| 1689 | ||
| 1690 | test "the picker is reusable after it answers" { | |
| 1691 | // The refusal above must not be permanent. Once a delegate callback has | |
| 1692 | // taken the slot, the next pickContact publishes normally. | |
| 1693 | resetPendingForTesting(); | |
| 1694 | defer resetPendingForTesting(); | |
| 1695 | ||
| 1696 | try testing.expect(publishPending(.{ .ticket = fakeTicket(0, 1), .sels = fakeSels() })); | |
| 1697 | _ = takePending(); | |
| 1698 | try testing.expect(publishPending(.{ .ticket = fakeTicket(0, 3), .sels = fakeSels() })); | |
| 1699 | } | |
| 1700 | ||
| 1701 | test "a delegate callback with no request waiting takes nothing" { | |
| 1702 | // The stray-callback path every IMP begins with. It has to be a | |
| 1703 | // no-op rather than an answer, because the next thing to occupy the slot | |
| 1704 | // would otherwise receive this picker's result. | |
| 1705 | resetPendingForTesting(); | |
| 1706 | defer resetPendingForTesting(); | |
| 1707 | ||
| 1708 | try testing.expect(takePending() == null); | |
| 1709 | } | |
| 1710 | ||
| 1711 | test "the delegate class name is this module's alone" { | |
| 1712 | // `ios_delegate.defineClass` looks a name up before allocating, so two files | |
| 1713 | // registering one name means whichever ran second silently adopts the | |
| 1714 | // other's IMPs — which read different objects and answer different tickets. | |
| 1715 | try testing.expectEqualStrings("CraftIOSContactPickerDelegate", delegate_class_name); | |
| 1716 | } | |
| 1717 | ||
| 1718 | test "every delegate method is registered at the encoding its selector needs" { | |
| 1719 | // A wrong encoding still registers and still dispatches, then reads | |
| 1720 | // arguments from the wrong registers with no crash and no compile error. The | |
| 1721 | // named constants are the only defence, so the *pairing* is pinned rather | |
| 1722 | // than the constants alone: both `didSelect` selectors take the picker plus | |
| 1723 | // one object, the cancel and the dismissal take one object each. | |
| 1724 | // | |
| 1725 | // The count is asserted too. `contactPicker:didSelectContacts:` is the whole | |
| 1726 | // `multiple:true` reply path and `presentationControllerDidDismiss:` is the | |
| 1727 | // only answer to a swipe-down; UIKit dispatches through | |
| 1728 | // `respondsToSelector:`, so dropping either is a silent no-op and a promise | |
| 1729 | // that never settles. | |
| 1730 | const methods = delegateMethods(); | |
| 1731 | try testing.expectEqual(@as(usize, 4), methods.len); | |
| 1732 | ||
| 1733 | const want = [_]struct { selector: []const u8, types: []const u8 }{ | |
| 1734 | .{ .selector = "contactPicker:didSelectContact:", .types = "v@:@@" }, | |
| 1735 | .{ .selector = "contactPicker:didSelectContacts:", .types = "v@:@@" }, | |
| 1736 | .{ .selector = "contactPickerDidCancel:", .types = "v@:@" }, | |
| 1737 | .{ .selector = "presentationControllerDidDismiss:", .types = "v@:@" }, | |
| 1738 | }; | |
| 1739 | ||
| 1740 | for (methods, 0..) |method, i| { | |
| 1741 | try testing.expectEqualStrings(want[i].selector, method.selector); | |
| 1742 | try testing.expectEqualStrings(want[i].types, method.types); | |
| 1743 | } | |
| 1744 | ||
| 1745 | // And the encodings really are the named ones, not two strings that happen | |
| 1746 | // to match today. | |
| 1747 | try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[0].types); | |
| 1748 | try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[1].types); | |
| 1749 | try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[2].types); | |
| 1750 | try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[3].types); | |
| 1751 | } | |
| 1752 | ||
| 1753 | test "no two delegate methods share a selector" { | |
| 1754 | // `class_addMethod` for a selector the class already has is a failure the | |
| 1755 | // factory turns into `MethodNotAdded`, so a duplicate would take the whole | |
| 1756 | // registration down — and a typo that made two rows equal would otherwise | |
| 1757 | // read as an extra method rather than a missing one. | |
| 1758 | const methods = delegateMethods(); | |
| 1759 | for (methods, 0..) |a, i| { | |
| 1760 | for (methods[i + 1 ..]) |b| { | |
| 1761 | try testing.expect(!std.mem.eql(u8, a.selector, b.selector)); | |
| 1762 | } | |
| 1763 | } | |
| 1764 | } | |
| 1765 | ||
| 1766 | test "a swipe-down that sends no cancel still empties the slot exactly once" { | |
| 1767 | // The belt's contract, as state rather than as prose: whichever callback | |
| 1768 | // arrives first takes the ticket, and the second finds nothing. Without it a | |
| 1769 | // swipe would leave `pending` set for the life of the process and refuse | |
| 1770 | // every later pickContact — which is worse here than in Swift, where only | |
| 1771 | // the one promise is stranded. | |
| 1772 | resetPendingForTesting(); | |
| 1773 | defer resetPendingForTesting(); | |
| 1774 | ||
| 1775 | try testing.expect(publishPending(.{ .ticket = fakeTicket(2, 5), .sels = fakeSels() })); | |
| 1776 | ||
| 1777 | // The dismissal gets there first: it takes the ticket... | |
| 1778 | const swiped = takePending() orelse return error.PendingWentMissing; | |
| 1779 | try testing.expectEqual(@as(u5, 2), swiped.ticket.index); | |
| 1780 | ||
| 1781 | // ...and a late `contactPickerDidCancel:` for the same picker finds nothing | |
| 1782 | // to answer, so the promise is rejected once rather than twice. | |
| 1783 | try testing.expect(takePending() == null); | |
| 1784 | ||
| 1785 | // The slot is free again, not wedged. | |
| 1786 | try testing.expect(publishPending(.{ .ticket = fakeTicket(2, 7), .sels = fakeSels() })); | |
| 1787 | } | |
| 1788 | ||
| 1789 | test "nothing in this module dismisses the picker" { | |
| 1790 | // `CNContactPickerViewController` is a remote view controller that takes | |
| 1791 | // itself down, and Swift dismisses it in none of its three delegate methods. | |
| 1792 | // A `dismiss` added here would be a second dismissal of a controller already | |
| 1793 | // dismissing — and on the presenter it would take down whatever else is | |
| 1794 | // modal. This is a real difference from the image and document pickers, so | |
| 1795 | // it is asserted rather than left to a comment. | |
| 1796 | // | |
| 1797 | // `presentationControllerDidDismiss:` does not contradict this: it *observes* | |
| 1798 | // a dismissal the user performed and never asks for one. | |
| 1799 | // | |
| 1800 | // The needle is the *call* form — a `selector(` with a dismissal name in it | |
| 1801 | // — rather than the bare selector name, so the prose in this file cannot | |
| 1802 | // match itself and the assertion keeps meaning something. Which is why the | |
| 1803 | // needle is not spelled out in any comment, including this one. | |
| 1804 | const source = @embedFile("bridge_mobile_contactpicker.zig"); | |
| 1805 | try testing.expect(std.mem.indexOf(u8, source, "selector(\"dismiss") == null); | |
| 1806 | // Non-vacuity: the same scan finds the present call, so a needle that | |
| 1807 | // stopped matching anything could not pass unnoticed. | |
| 1808 | try testing.expect(std.mem.indexOf(u8, source, "selector(\"presentViewController") != null); | |
| 1809 | } | |
packages/zig/src/bridge_mobile_filepicker.zigadded+1950-0
| @@ -0,0 +1,1950 @@ | ||
| 1 | //! The `mobile` namespace's two file actions: `pickFile` and `saveFile`. | |
| 2 | //! | |
| 3 | //! They look like a pair and are not. `pickFile` presents | |
| 4 | //! `UIDocumentPickerViewController`, so its answer arrives from a delegate | |
| 5 | //! callback long after the dispatch frame is gone and needs an `ios_async` | |
| 6 | //! ticket. `saveFile` presents nothing at all — it writes straight into | |
| 7 | //! `<container>/Documents/<filename>` and resolves the path from inside the | |
| 8 | //! dispatch. The third member of the family, `downloadFile`, is deliberately | |
| 9 | //! left with the Swift shim; see `deliberately_unserved`. | |
| 10 | //! | |
| 11 | //! ## Neither action has any injected JavaScript, so the field names come from | |
| 12 | //! ## the Swift dispatcher | |
| 13 | //! | |
| 14 | //! `window.craft.pickFile` and `window.craft.saveFile` **do not exist** in the | |
| 15 | //! injected iOS bridge. `injectNativeBridge` spans `CraftApp.swift:1164`-`2426` | |
| 16 | //! and contains no occurrence of `file` at all, and `craft-bridge.js` has no | |
| 17 | //! `mobile` surface either (its `saveFile` is `_req('dialog','saveFile',…)` — | |
| 18 | //! the desktop dialog namespace, a different action in a different namespace). | |
| 19 | //! So `test-bridges.html:585` and both README examples are `TypeError`s on the | |
| 20 | //! legacy Swift path, and the dispatcher arms are reachable only through the | |
| 21 | //! Zig envelope `{t:"mobile", a:"pickFile"|"saveFile", d, i}`. That is a | |
| 22 | //! pre-existing bug, the same one `bridge_mobile_motion.zig` records for | |
| 23 | //! `startMotionUpdates`, and it is why the payload field names below are read | |
| 24 | //! off the Swift dispatcher's own `body[...]` reads and cross-checked against | |
| 25 | //! `craft.d.ts` and Android's `CraftBridge.kt.template` rather than off an | |
| 26 | //! injected method that was never written. | |
| 27 | //! | |
| 28 | //! ## The payloads, exactly | |
| 29 | //! | |
| 30 | //! **`pickFile`** reads one optional field, `types`, an array of strings | |
| 31 | //! (`CraftApp.swift:817`). Nothing else is read. Absent or null means Swift's | |
| 32 | //! default of a single `UTTypeItem`. | |
| 33 | //! | |
| 34 | //! **`saveFile`** reads two required fields, `data` and `filename` | |
| 35 | //! (`CraftApp.swift:829-831`). There is no third field: `craft.d.ts:217` | |
| 36 | //! declares a `mimeType` parameter and the README passes one, but neither | |
| 37 | //! Swift nor Android ever reads it. It is a type-declaration fiction, so a | |
| 38 | //! `mimeType` in the payload is accepted and ignored rather than invented into | |
| 39 | //! a field or turned into an error. | |
| 40 | //! | |
| 41 | //! ## The reply shapes, exactly | |
| 42 | //! | |
| 43 | //! **`pickFile` resolves an object** (`CraftApp.swift:4897-4902`): | |
| 44 | //! `{"name":…,"path":…,"data":"data:<mime>;base64,<b64>","mimeType":…}`. The | |
| 45 | //! `data:` URL uses the same derived MIME string, and the base64 is | |
| 46 | //! `-[NSData base64EncodedStringWithOptions:0]` — standard alphabet, padded, | |
| 47 | //! unwrapped. Key order is Swift's literal order; Swift's own is a `Dictionary` | |
| 48 | //! and therefore arbitrary, so one order is fixed here to make the bytes | |
| 49 | //! testable. | |
| 50 | //! | |
| 51 | //! **`pickFile` rejects on cancel.** `documentPickerWasCancelled:` calls | |
| 52 | //! `rejectCallback(pendingCallbackId, error: "Cancelled")` | |
| 53 | //! (`CraftApp.swift:4913`), so a cancel is a rejected promise and not a | |
| 54 | //! distinct resolve — the opposite of the shape a picker often has, and worth | |
| 55 | //! reading twice. An empty `urls` array is likewise a reject | |
| 56 | //! (`"No file selected"`, `CraftApp.swift:4889`). | |
| 57 | //! | |
| 58 | //! **`saveFile` resolves a bare JSON string**, the written path | |
| 59 | //! (`CraftApp.swift:3658` through `resolveCallback`'s `.fragmentsAllowed`). | |
| 60 | //! Not an object. `craft.d.ts:217` types it `Promise<void>`, but Android agrees | |
| 61 | //! with Swift (`file.absolutePath`), and `craft-bridge.js` settles with | |
| 62 | //! `payload || {}` — a non-empty path survives that, and the path can never be | |
| 63 | //! empty here, so there is no truthiness trap. | |
| 64 | //! | |
| 65 | //! ## The config gate Zig cannot see, and why both actions are served anyway | |
| 66 | //! | |
| 67 | //! Swift wraps both arms in `if config.enableFilePicker` / | |
| 68 | //! `if config.enableFileDownload`, each defaulting to **false** | |
| 69 | //! (`CraftApp.swift:206-207`), each with **no `else`**. So with the default | |
| 70 | //! config the Swift path replies *nothing at all* while | |
| 71 | //! `CraftSwiftShim.handleAction` still returns true: the page's promise never | |
| 72 | //! settles. `ios.zig`'s `AppConfig` has no mirror of either flag, and — unlike | |
| 73 | //! motion, where `NSMotionUsageDescription` is an exact proxy — there is no | |
| 74 | //! Info.plist key to read instead: `renderUsageDescriptions` | |
| 75 | //! (`packages/ios/src/index.ts:183-199`) writes nothing from either flag. | |
| 76 | //! | |
| 77 | //! Both are therefore served unconditionally, and an app that set | |
| 78 | //! `enableFilePicker: false` or `enableFileDownload: false` now gets the | |
| 79 | //! behaviour anyway. Restoring the gate means adding the fields to | |
| 80 | //! `AppConfig`, not adding a guess here — the same trade | |
| 81 | //! `bridge_mobile_system.zig` records word for word for `share`. | |
| 82 | //! | |
| 83 | //! Falling through to the shim is not the safe option it was for | |
| 84 | //! `takeScreenshot`, because the shim does not answer these correctly today: | |
| 85 | //! with the flag off it answers with silence, and `ios_dispatch.zig:170-174` | |
| 86 | //! names silence as the one outcome ruled out. `.unavailable` is strictly | |
| 87 | //! worse again — `ios_dispatch.route` only reaches the shim when every module | |
| 88 | //! returns `UnknownAction`, so declaring it would make Zig dispatch and refuse. | |
| 89 | //! | |
| 90 | //! What makes serving them defensible rather than merely better-than-silence: | |
| 91 | //! `pickFile` is user-mediated, so nothing is read without the user tapping a | |
| 92 | //! file, and `saveFile` writes only inside the app's own container, needs no | |
| 93 | //! OS permission and shows no UI. | |
| 94 | //! | |
| 95 | //! ## Five deliberate divergences from Swift | |
| 96 | //! | |
| 97 | //! **1. Security-scoped access is taken around the read.** Swift's | |
| 98 | //! `documentPicker(_:didPickDocumentsAt:)` is a bare `try? Data(contentsOf: url)` | |
| 99 | //! with no `startAccessingSecurityScopedResource` anywhere in the repo. Because | |
| 100 | //! the picker is built with `asCopy == NO`, the URL is a security-scoped | |
| 101 | //! reference to a document outside the container: on a device that read | |
| 102 | //! **fails**, `try?` yields nil, and Swift falls to its degraded branch. In the | |
| 103 | //! simulator the same read usually succeeds, which is why the bug has never | |
| 104 | //! been noticed. This module takes the scope and releases it in a `defer`. | |
| 105 | //! Swift does *not* do this; the claim is only about what this file does. | |
| 106 | //! | |
| 107 | //! **2. A file whose bytes cannot be read is rejected, not resolved.** Swift | |
| 108 | //! resolves `{name, path}` with no `data` and no `mimeType` | |
| 109 | //! (`CraftApp.swift:4903-4906`) — without the field its own `PickedFile` type | |
| 110 | //! declares required and the only in-repo consumer reads | |
| 111 | //! (`test-bridges.html:589` reads `file?.data?.length`). With the scoping fix | |
| 112 | //! above, a failed read is a real failure, and resolving a data-less object for | |
| 113 | //! it is fabricated-adjacent success. It is a rejection here. | |
| 114 | //! | |
| 115 | //! **3. An unmappable `types` entry is refused rather than dropped.** Swift's | |
| 116 | //! `compactMap` silently discards entries it cannot map, and can therefore | |
| 117 | //! produce an *empty* allowed-types array. Dropping a field the page sent is | |
| 118 | //! the failure rule 2 exists for, so an entry that maps to nothing is an | |
| 119 | //! `INVALID_PARAMETER` naming it. | |
| 120 | //! | |
| 121 | //! **4. `+[UTType typeWithIdentifier:]` is tried as a third fallback**, after | |
| 122 | //! Swift's `typeWithMIMEType:` then `typeWithFilenameExtension:`. This is an | |
| 123 | //! intentional improvement, declared rather than slipped in: Swift never tries | |
| 124 | //! it, so a bare type identifier maps to nothing at all there. It never widens | |
| 125 | //! past what the page asked for — an entry that names a declared identifier | |
| 126 | //! becomes exactly that type, and one that names nothing is still refused. | |
| 127 | //! | |
| 128 | //! What it does *not* do is rescue the README's own documented call. Measured | |
| 129 | //! on the host UniformTypeIdentifiers: `public.image` maps by identifier, but | |
| 130 | //! `public.pdf` is not a declared identifier at all (the real one is | |
| 131 | //! `com.adobe.pdf`) and maps by none of the three routes, so | |
| 132 | //! `pickFile(['public.image','public.pdf'])` is refused by divergence 3 — where | |
| 133 | //! Swift silently `compactMap`s both away and presents a picker with an empty | |
| 134 | //! allowed-types array. The refusal is the honest answer; the README is what | |
| 135 | //! needs fixing, and that is not this file's change to make. | |
| 136 | //! | |
| 137 | //! One measured caveat on divergence 3's reach: `typeWithFilenameExtension:` | |
| 138 | //! answers a *dynamic* type (`dyn.…`) for an arbitrary dot-free string, so a | |
| 139 | //! garbage entry like `"notanextension"` is accepted as that dynamic type | |
| 140 | //! rather than refused. Nothing is fabricated — the picker simply matches no | |
| 141 | //! file — and it is exactly what Swift's second `??` arm does too; the refusal | |
| 142 | //! path is reached by dotted non-identifiers, which is where Swift's silent | |
| 143 | //! drop actually bites. | |
| 144 | //! | |
| 145 | //! **5. `filename` is checked before it is joined.** Swift applies | |
| 146 | //! `appendingPathComponent` to an unsanitised page-supplied string | |
| 147 | //! (`CraftApp.swift:3645`), so `"../Library/Preferences/x.plist"` escapes | |
| 148 | //! `Documents`. A name containing `/` or a NUL, an empty name, and the two | |
| 149 | //! names `.` and `..` are refused here. With `/` excluded, `..` can only ever | |
| 150 | //! be the whole name, so those four checks are exhaustive for traversal — and | |
| 151 | //! an ordinary name like `my..notes.txt` still passes. | |
| 152 | //! | |
| 153 | //! And one bug that is simply not reproduced: Swift's `saveFile` reaches | |
| 154 | //! `resolveCallback(callbackId, result: fileURL.path)` even when the data URL | |
| 155 | //! had the wrong number of comma-separated parts or its base64 would not decode | |
| 156 | //! (`CraftApp.swift:3651-3658` — the `if` body just never runs, nothing throws). | |
| 157 | //! `data:text/plain,hello` triggers it: the page is told a file was written | |
| 158 | //! where nothing was. Here both are `INVALID_PARAMETER`. | |
| 159 | //! | |
| 160 | //! ## Dismissal | |
| 161 | //! | |
| 162 | //! `UIDocumentPickerViewController` dismisses itself before calling its | |
| 163 | //! delegate — which is why Swift's two delegate methods contain no `dismiss` | |
| 164 | //! call, in visible contrast to the image picker, which dismisses explicitly at | |
| 165 | //! `CraftApp.swift:2643` and `2662`. Sending an unconditional dismiss would | |
| 166 | //! therefore be a *second* dismiss, and a second dismiss is not a no-op: | |
| 167 | //! `-dismissViewControllerAnimated:completion:` on a controller with nothing | |
| 168 | //! presented forwards up the presentation chain and can tear down something | |
| 169 | //! nobody asked to close. | |
| 170 | //! | |
| 171 | //! So both callbacks dismiss, and both check first: `dismissPicker` reads the | |
| 172 | //! controller's own `presentingViewController` and does nothing when it is nil | |
| 173 | //! (already dismissed itself). When it is non-nil the picker really is still on | |
| 174 | //! screen and is dismissed by sending the message to the picker itself, which | |
| 175 | //! UIKit routes to its presenter. The app cannot end up parked behind a modal, | |
| 176 | //! and nothing unrelated is dismissed either. | |
| 177 | //! | |
| 178 | //! ## The swipe-down belt | |
| 179 | //! | |
| 180 | //! A modal the delegate never hears about is the other half of the problem, and | |
| 181 | //! it is the *page* rather than the app that pays for it. The picker is a sheet, | |
| 182 | //! and an interactive dismissal is widely reported not to send | |
| 183 | //! `documentPickerWasCancelled:` — the same report `bridge_mobile_imagepicker.zig` | |
| 184 | //! and `bridge_mobile_contactpicker.zig` carry belts for, and one this repo | |
| 185 | //! verifies on no device. Without a belt a swipe leaves `pending_pick` set | |
| 186 | //! forever: the call is never answered (silence, which `ios_dispatch.zig:170-174` | |
| 187 | //! rules out) **and** every later `pickFile` is refused for the life of the | |
| 188 | //! process, which is strictly worse than Swift, where only the one promise is | |
| 189 | //! stranded. | |
| 190 | //! | |
| 191 | //! `presentationControllerDidDismiss:` is that belt: it is registered on the | |
| 192 | //! same delegate, and the picker's presentation controller is pointed at that | |
| 193 | //! delegate before presenting and again after, so a swipe *rejects* the call | |
| 194 | //! rather than stranding it. Belt, not fix — UIKit sends it only for a | |
| 195 | //! user-driven dismissal, so it cannot fire after a pick or a Cancel that | |
| 196 | //! already answered, and if the report is wrong it costs two messages. The one | |
| 197 | //! case it does not cover is a presentation controller that already carries a | |
| 198 | //! delegate of its own, which is left alone rather than displaced; that is | |
| 199 | //! logged where it happens rather than assumed away. | |
| 200 | //! | |
| 201 | //! ## One pending pick, refused rather than replaced | |
| 202 | //! | |
| 203 | //! Swift keeps a single `pendingCallbackId` (`CraftApp.swift:383`) shared | |
| 204 | //! across the camera, the image picker, the document picker, the contact | |
| 205 | //! picker, the QR scanner, Apple Sign-In, NFC and Bluetooth, and overwrites it | |
| 206 | //! unconditionally — so two overlapping picks strand the first promise forever. | |
| 207 | //! A picker is modal and serves exactly one call, so this module keeps exactly | |
| 208 | //! one pending ticket for this picker kind, under a mutex, and a second | |
| 209 | //! `pickFile` issued while one is presented is an explicit error to the | |
| 210 | //! *second* caller. The first ticket is never replaced. | |
| 211 | //! | |
| 212 | //! ## Ceilings and simulator caveats | |
| 213 | //! | |
| 214 | //! The whole file crosses the bridge base64-encoded inside one | |
| 215 | //! `evaluateJavaScript` string — about 1.37 bytes of JavaScript source per byte | |
| 216 | //! of file. Swift has exactly the same ceiling; it is noted here rather than | |
| 217 | //! discovered on a 200 MB video. | |
| 218 | //! | |
| 219 | //! `UIDocumentPickerViewController` itself works in the simulator — it is not | |
| 220 | //! camera-class hardware, so the "unavailable on simulator, must reject" | |
| 221 | //! precedent does not apply. What the simulator lacks is content to pick, and | |
| 222 | //! the `zig-slice` fixture cannot tap Cancel or choose a file anyway, so only | |
| 223 | //! `pickFile`'s refusal paths are reachable from that harness. `saveFile` is | |
| 224 | //! fully fixture-testable. Security scoping is invisible in the simulator, | |
| 225 | //! where URLs resolve to ordinary host paths and the read succeeds without it — | |
| 226 | //! a green simulator run proves nothing about divergence 1. | |
| 227 | //! | |
| 228 | //! `objc_getClass("UTType")` may be null in the Zig-only fixture: | |
| 229 | //! `packages/ios/fixtures/zig-slice/build-and-run.sh:65` links only | |
| 230 | //! UIKit/WebKit/Foundation/Security, while the generated app pulls | |
| 231 | //! UniformTypeIdentifiers in through `CraftApp.swift:20`. A null class is a | |
| 232 | //! named error with a log line naming the missing framework — never a silent | |
| 233 | //! fallback to the `[.item]` default, which would quietly widen the file types | |
| 234 | //! the app author asked to restrict. | |
| 235 | ||
| 236 | const std = ; | |
| 237 | const builtin = ; | |
| 238 | const capabilities = ; | |
| 239 | const bridge_error = ; | |
| 240 | const objc_runtime = ; | |
| 241 | const ios_async = ; | |
| 242 | const ios_delegate = ; | |
| 243 | const bridge_mobile_system = ; | |
| 244 | const compat_mutex = ; | |
| 245 | ||
| 246 | const objc = objc_runtime.objc; | |
| 247 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 248 | const BridgeError = bridge_error.BridgeError; | |
| 249 | ||
| 250 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally. | |
| 251 | /// | |
| 252 | /// `objc_runtime.objc` is an empty struct off Darwin, and a `callconv(.c)` | |
| 253 | /// fn-pointer *type* is analysed even where a comptime platform guard makes the | |
| 254 | /// surrounding code unreachable, so naming `objc.id` in the delegate signatures | |
| 255 | /// below would break the host build. A single optional pointer, never | |
| 256 | /// `?objc.id`: a double optional is illegal in a `callconv(.c)` signature. | |
| 257 | const Id = ?*anyopaque; | |
| 258 | ||
| 259 | /// The action names, spelled exactly as the Swift `case` labels spell them. | |
| 260 | /// | |
| 261 | /// `test/ios_conformance_test.zig` matches this block against those labels in | |
| 262 | /// both directions and fails the build if two modules declare one name. Keep | |
| 263 | /// the doc comments in this block free of quoted text: the scanner takes the | |
| 264 | /// first quoted string on every line it sees between the header and the closing | |
| 265 | /// brace. | |
| 266 | pub const A = struct { | |
| 267 | pub const pick_file = "pickFile"; | |
| 268 | pub const save_file = "saveFile"; | |
| 269 | }; | |
| 270 | ||
| 271 | /// The third action of this family, and on purpose not served. | |
| 272 | /// | |
| 273 | /// `downloadFile` is out of scope for this round. Recorded as data rather than | |
| 274 | /// prose so a test can hold the two properties the decision rests on: it is not | |
| 275 | /// declared, and it still falls out of `handleMessage` as `UnknownAction` — | |
| 276 | /// the one return `ios_dispatch.route` converts into a Swift-shim hand-off. | |
| 277 | /// Whoever migrates it moves the name into `A` and updates the module comment. | |
| 278 | pub const deliberately_unserved = [_][]const u8{ | |
| 279 | "downloadFile", | |
| 280 | }; | |
| 281 | ||
| 282 | /// Both `.result`: each Swift path terminates in exactly one `resolveCallback` | |
| 283 | /// or one `rejectCallback`, and the page awaits both. `.none` on either would | |
| 284 | /// resolve a caller immediately with nothing. | |
| 285 | /// | |
| 286 | /// Both `.live`: a refusal here is a specific failure — a cancelled pick, an | |
| 287 | /// unreadable file, a filename that would escape the container, a full async | |
| 288 | /// pool — never the action's normal answer. `.unavailable` is for an action | |
| 289 | /// that dispatches and refuses, which neither of these is. | |
| 290 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 291 | .{ .name = A.pick_file, .reply = .result }, | |
| 292 | .{ .name = A.save_file, .reply = .result }, | |
| 293 | }; | |
| 294 | ||
| 295 | /// Which handler an action selects, split out of `handleMessage` so the | |
| 296 | /// table-versus-dispatch agreement is assertable on a host without UIKit. | |
| 297 | const Route = enum { pick_file, save_file }; | |
| 298 | ||
| 299 | fn routeFor(action: []const u8) ?Route { | |
| 300 | if (std.mem.eql(u8, action, A.pick_file)) return .pick_file; | |
| 301 | if (std.mem.eql(u8, action, A.save_file)) return .save_file; | |
| 302 | return null; | |
| 303 | } | |
| 304 | ||
| 305 | pub const FilePickerBridge = struct { | |
| 306 | allocator: std.mem.Allocator, | |
| 307 | ||
| 308 | const Self = @This(); | |
| 309 | ||
| 310 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 311 | return .{ .allocator = allocator }; | |
| 312 | } | |
| 313 | ||
| 314 | pub fn deinit(_: *Self) void {} | |
| 315 | ||
| 316 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 317 | const route = routeFor(action) orelse return BridgeError.UnknownAction; | |
| 318 | // Exhaustive, so a `Route` without a handler is a compile error. | |
| 319 | return switch (route) { | |
| 320 | .pick_file => self.pickFile(data), | |
| 321 | .save_file => self.saveFile(data), | |
| 322 | }; | |
| 323 | } | |
| 324 | ||
| 325 | /// Present the document picker and answer when the user has chosen or | |
| 326 | /// cancelled. | |
| 327 | /// | |
| 328 | /// Every fallible step runs *before* `ios_async.acquire`, and the only | |
| 329 | /// thing between publishing the ticket and presenting is the presentation | |
| 330 | /// itself. A failure after the lease would have to release the slot by | |
| 331 | /// hand, and a missed release is a permanently narrower pool. | |
| 332 | fn pickFile(self: *Self, data: []const u8) !void { | |
| 333 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 334 | ||
| 335 | const request = try parsePickRequest(self.allocator, data); | |
| 336 | defer request.deinit(self.allocator); | |
| 337 | ||
| 338 | // Guarded once, here, for both uses below: the default `public.item` | |
| 339 | // and every mapped entry need this class. A null answer means the | |
| 340 | // process has no UniformTypeIdentifiers — the fixture's link line — and | |
| 341 | // that is an error, never a silent fall back to the default, which | |
| 342 | // would widen the types the app author asked to restrict. | |
| 343 | const UTTypeClass = objc.objc_getClass("UTType") orelse { | |
| 344 | std.log.err( | |
| 345 | "pickFile: this process has no UTType class, so no content type can be " ++ | |
| 346 | "built; the app or fixture needs -framework UniformTypeIdentifiers", | |
| 347 | .{}, | |
| 348 | ); | |
| 349 | return BridgeError.NativeCallFailed; | |
| 350 | }; | |
| 351 | ||
| 352 | const content_types = try buildContentTypes(self.allocator, request, UTTypeClass); | |
| 353 | const delegate = try ensureDelegate(); | |
| 354 | ||
| 355 | const PickerClass = objc.objc_getClass("UIDocumentPickerViewController") orelse | |
| 356 | return error.ClassNotFound; | |
| 357 | ||
| 358 | // `initForOpeningContentTypes:` — the one-argument iOS 14+ form, which | |
| 359 | // is what Swift's single-label call binds to, i.e. `asCopy == NO`. | |
| 360 | // Deployment target is 16.0, so its availability is unconditional. The | |
| 361 | // deprecated `initWithDocumentTypes:inMode:` is not used. | |
| 362 | const sel_init = try selector("initForOpeningContentTypes:"); | |
| 363 | const sel_set_delegate = try selector("setDelegate:"); | |
| 364 | const sel_set_multi = try selector("setAllowsMultipleSelection:"); | |
| 365 | const sel_present = try selector("presentViewController:animated:completion:"); | |
| 366 | // The swipe-down belt's reads, resolved here with everything else so a | |
| 367 | // missing selector fails the call rather than silently leaving the only | |
| 368 | // thing that answers an interactive dismissal unattached. | |
| 369 | const dismiss_sels = DismissSels{ | |
| 370 | .presentation = try selector("presentationController"), | |
| 371 | .delegate = try selector("delegate"), | |
| 372 | .set_delegate = sel_set_delegate, | |
| 373 | }; | |
| 374 | ||
| 375 | const allocated = try objc.alloc(PickerClass); | |
| 376 | const InitFn = *const fn (Id, Id, Id) callconv(.c) Id; | |
| 377 | const init_fn: InitFn = @ptrCast(&objc.objc_msgSend); | |
| 378 | const picker = init_fn(allocated, sel_init, content_types) orelse | |
| 379 | return error.NativeCallFailed; | |
| 380 | // `alloc` gave us +1 and `init` consumed it. The presenter retains the | |
| 381 | // picker for the life of the presentation, so ours is the reference to | |
| 382 | // drop — and on the refusal paths below it is the only one there is. | |
| 383 | defer objc.release(picker); | |
| 384 | ||
| 385 | // The picker holds its delegate **weakly** (`UIDocumentPickerViewController.h:65`), | |
| 386 | // which is why `delegate_instance` is a module-level var rather than a | |
| 387 | // local: dropping it would be a use-after-free the first time the user | |
| 388 | // taps Cancel. | |
| 389 | objc.msgSendVoid1(picker, sel_set_delegate, delegate); | |
| 390 | ||
| 391 | const SetBoolFn = *const fn (Id, Id, bool) callconv(.c) void; | |
| 392 | const set_bool: SetBoolFn = @ptrCast(&objc.objc_msgSend); | |
| 393 | // Swift sets this explicitly at `CraftApp.swift:3603` even though false | |
| 394 | // is the default, and the reply shape has room for exactly one file. | |
| 395 | set_bool(picker, sel_set_multi, false); | |
| 396 | ||
| 397 | // Swift presents on `windowScene.windows.first?.rootViewController` | |
| 398 | // (`CraftApp.swift:3594`), which logs "which is already presenting" and | |
| 399 | // silently does nothing when anything else is up — leaving the promise | |
| 400 | // unsettled. This walks `presentedViewController` from the webview's own | |
| 401 | // window instead. A declared deviation, and strictly the more correct | |
| 402 | // one. No `anchorPopover` call: the picker's default presentation style | |
| 403 | // is a form/page sheet, so `popoverPresentationController` is nil and | |
| 404 | // there is nothing to anchor — unlike `UIActivityViewController`, which | |
| 405 | // traps without one. | |
| 406 | const presenter = try bridge_mobile_system.uikit.topmostViewController(); | |
| 407 | ||
| 408 | // Attached before presenting, because that is when a presentation | |
| 409 | // controller can already exist, and retried after `presentViewController:` | |
| 410 | // for the case where UIKit only builds one there. Infallible either way: | |
| 411 | // it reports whether it took and never fails the call. | |
| 412 | var dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels); | |
| 413 | ||
| 414 | const ticket = ios_async.acquire(A.pick_file) orelse return poolFull(); | |
| 415 | ||
| 416 | // Published before the framework call, never after: the delegate can in | |
| 417 | // principle be reached before `present` returns, and a callback that | |
| 418 | // arrived at an empty slot would have no ticket to reply with. | |
| 419 | if (!publishPendingPick(ticket)) { | |
| 420 | // Swift overwrites its one `pendingCallbackId` here and the first | |
| 421 | // caller's promise never settles. The second caller is refused | |
| 422 | // instead; the first keeps the picker and its ticket. | |
| 423 | ios_async.abandon(ticket); | |
| 424 | std.log.warn( | |
| 425 | "pickFile refused: a document picker is already presented and owns the " ++ | |
| 426 | "pending reply; refusing the second call rather than stranding the first", | |
| 427 | .{}, | |
| 428 | ); | |
| 429 | return BridgeError.InvalidParameter; | |
| 430 | } | |
| 431 | ||
| 432 | const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void; | |
| 433 | const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend); | |
| 434 | present_fn(presenter, sel_present, picker, true, null); | |
| 435 | ||
| 436 | if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, dismiss_sels); | |
| 437 | if (!dismiss_observed) { | |
| 438 | std.log.warn( | |
| 439 | "pickFile: the picker's presentation controller could not be observed " ++ | |
| 440 | "(absent, or already carrying a delegate this file will not displace); " ++ | |
| 441 | "if a swipe-down dismissal does not send documentPickerWasCancelled: " ++ | |
| 442 | "then this call stays unanswered and every later one is refused", | |
| 443 | .{}, | |
| 444 | ); | |
| 445 | } | |
| 446 | } | |
| 447 | ||
| 448 | /// Write `data` into `<container>/Documents/<filename>` and resolve the | |
| 449 | /// path. | |
| 450 | /// | |
| 451 | /// Fully synchronous: Swift presents nothing here — no export-mode picker, | |
| 452 | /// no temp file, no delegate — so there is no `ios_async` ticket and the | |
| 453 | /// reply goes out from inside the dispatch frame that holds the request id. | |
| 454 | fn saveFile(self: *Self, data: []const u8) !void { | |
| 455 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 456 | ||
| 457 | const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch | |
| 458 | return BridgeError.InvalidJSON; | |
| 459 | defer parsed.deinit(); | |
| 460 | ||
| 461 | const root = switch (parsed.value) { | |
| 462 | .object => |obj| obj, | |
| 463 | else => return BridgeError.InvalidJSON, | |
| 464 | }; | |
| 465 | ||
| 466 | const request = try parseSaveRequest(root); | |
| 467 | ||
| 468 | const bytes = try decodeSaveBytes(self.allocator, request.data); | |
| 469 | defer self.allocator.free(bytes); | |
| 470 | ||
| 471 | const directory = try documentsPath(self.allocator); | |
| 472 | defer self.allocator.free(directory); | |
| 473 | ||
| 474 | const path = try std.fmt.allocPrint( | |
| 475 | self.allocator, | |
| 476 | "{s}/{s}", | |
| 477 | .{ directory, request.filename }, | |
| 478 | ); | |
| 479 | defer self.allocator.free(path); | |
| 480 | ||
| 481 | try writeFileAtomically(self.allocator, path, bytes); | |
| 482 | ||
| 483 | const json = try shapeSavedPath(self.allocator, path); | |
| 484 | defer self.allocator.free(json); | |
| 485 | ||
| 486 | bridge_error.sendResultToJS(self.allocator, A.save_file, json); | |
| 487 | } | |
| 488 | }; | |
| 489 | ||
| 490 | /// The answer for a full block pool, in the shape `bridge_mobile_location.zig` | |
| 491 | /// uses: `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration | |
| 492 | /// notes' designated stand-in, and the point is that the seventeenth concurrent | |
| 493 | /// call gets an explicit rejection instead of a promise that never settles. | |
| 494 | fn poolFull() BridgeError { | |
| 495 | std.log.warn("pickFile refused: all {d} async slots in flight", .{ios_async.max_in_flight}); | |
| 496 | return BridgeError.InvalidParameter; | |
| 497 | } | |
| 498 | ||
| 499 | fn selector(name: [*:0]const u8) !Id { | |
| 500 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 501 | return objc.sel_registerName(name) orelse error.SelectorNotFound; | |
| 502 | } | |
| 503 | ||
| 504 | // ============================================================================= | |
| 505 | // Payload parsing. Pure — no Objective-C — so every field the page can send is | |
| 506 | // driven by host tests on every platform. | |
| 507 | // ============================================================================= | |
| 508 | ||
| 509 | /// What `pickFile` read out of the payload. | |
| 510 | const PickRequest = struct { | |
| 511 | /// Null when the page sent no `types`, or sent null — Swift's `[.item]` | |
| 512 | /// default. Never an empty slice: an empty `types` array is refused at | |
| 513 | /// parse, so `buildContentTypes` can rely on having at least one entry. | |
| 514 | types: ?[][]const u8, | |
| 515 | ||
| 516 | fn deinit(self: PickRequest, allocator: std.mem.Allocator) void { | |
| 517 | const list = self.types orelse return; | |
| 518 | for (list) |entry| allocator.free(entry); | |
| 519 | allocator.free(list); | |
| 520 | } | |
| 521 | }; | |
| 522 | ||
| 523 | /// `types`, and nothing else — Swift reads no other key out of `body` for this | |
| 524 | /// action, and inventing one would create a way for the call to fail that the | |
| 525 | /// shim does not have. | |
| 526 | /// | |
| 527 | /// The entries are copied because the caller frees the parsed JSON before the | |
| 528 | /// picker is built. | |
| 529 | fn parsePickRequest(allocator: std.mem.Allocator, data: []const u8) !PickRequest { | |
| 530 | const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch | |
| 531 | return BridgeError.InvalidJSON; | |
| 532 | defer parsed.deinit(); | |
| 533 | ||
| 534 | const root = switch (parsed.value) { | |
| 535 | .object => |obj| obj, | |
| 536 | else => return BridgeError.InvalidJSON, | |
| 537 | }; | |
| 538 | ||
| 539 | const value = root.get("types") orelse return .{ .types = null }; | |
| 540 | const array = switch (value) { | |
| 541 | // `body["types"] as? [String]` yields nil for JSON null, and nil is | |
| 542 | // exactly the default case. | |
| 543 | .null => return .{ .types = null }, | |
| 544 | .array => |items| items, | |
| 545 | else => { | |
| 546 | std.log.warn("pickFile refused: types is present but is not an array", .{}); | |
| 547 | return BridgeError.InvalidParameter; | |
| 548 | }, | |
| 549 | }; | |
| 550 | ||
| 551 | if (array.items.len == 0) { | |
| 552 | // What `initForOpeningContentTypes:` does with an empty array is not | |
| 553 | // documented and was not verified, and Swift can reach it by way of a | |
| 554 | // `compactMap` that dropped everything. Refusing beats presenting a | |
| 555 | // picker whose behaviour nobody has established. | |
| 556 | std.log.warn("pickFile refused: types is an empty array", .{}); | |
| 557 | return BridgeError.InvalidParameter; | |
| 558 | } | |
| 559 | ||
| 560 | var list = try allocator.alloc([]const u8, array.items.len); | |
| 561 | var filled: usize = 0; | |
| 562 | errdefer { | |
| 563 | for (list[0..filled]) |entry| allocator.free(entry); | |
| 564 | allocator.free(list); | |
| 565 | } | |
| 566 | ||
| 567 | for (array.items) |item| { | |
| 568 | const text = switch (item) { | |
| 569 | .string => |s| s, | |
| 570 | else => { | |
| 571 | std.log.warn("pickFile refused: types contains a non-string entry", .{}); | |
| 572 | return BridgeError.InvalidParameter; | |
| 573 | }, | |
| 574 | }; | |
| 575 | list[filled] = try allocator.dupe(u8, text); | |
| 576 | filled += 1; | |
| 577 | } | |
| 578 | ||
| 579 | return .{ .types = list }; | |
| 580 | } | |
| 581 | ||
| 582 | /// What `saveFile` read out of the payload. Both slices borrow the parsed JSON, | |
| 583 | /// which the caller keeps alive for the whole write. | |
| 584 | const SaveRequest = struct { | |
| 585 | data: []const u8, | |
| 586 | filename: []const u8, | |
| 587 | }; | |
| 588 | ||
| 589 | /// `data` and `filename`, both required, and deliberately no third field. | |
| 590 | /// | |
| 591 | /// A `mimeType` in the payload is neither read nor refused: `craft.d.ts` and the | |
| 592 | /// README pass one, Swift and Android both ignore it, and erroring on it would | |
| 593 | /// break the documented call for a field that has never meant anything. | |
| 594 | fn parseSaveRequest(root: std.json.ObjectMap) !SaveRequest { | |
| 595 | const data_value = root.get("data") orelse { | |
| 596 | std.log.warn("saveFile refused: no data field", .{}); | |
| 597 | return BridgeError.MissingData; | |
| 598 | }; | |
| 599 | const filename_value = root.get("filename") orelse { | |
| 600 | std.log.warn("saveFile refused: no filename field", .{}); | |
| 601 | return BridgeError.MissingData; | |
| 602 | }; | |
| 603 | ||
| 604 | const data = switch (data_value) { | |
| 605 | .string => |s| s, | |
| 606 | else => { | |
| 607 | std.log.warn("saveFile refused: data is not a string", .{}); | |
| 608 | return BridgeError.InvalidParameter; | |
| 609 | }, | |
| 610 | }; | |
| 611 | const filename = switch (filename_value) { | |
| 612 | .string => |s| s, | |
| 613 | else => { | |
| 614 | std.log.warn("saveFile refused: filename is not a string", .{}); | |
| 615 | return BridgeError.InvalidParameter; | |
| 616 | }, | |
| 617 | }; | |
| 618 | ||
| 619 | try validateFilename(filename); | |
| 620 | ||
| 621 | return .{ .data = data, .filename = filename }; | |
| 622 | } | |
| 623 | ||
| 624 | /// Refuse a `filename` that would name a file outside `Documents`. | |
| 625 | /// | |
| 626 | /// Swift joins the page's string straight on with `appendingPathComponent`, so | |
| 627 | /// `"../Library/Preferences/x.plist"` escapes the directory. Four checks, and | |
| 628 | /// with `/` excluded they are exhaustive for traversal: a `..` component can | |
| 629 | /// then only be the entire name. An ordinary name that merely contains two dots | |
| 630 | /// — `my..notes.txt` — is not refused, because it names nothing but itself. | |
| 631 | fn validateFilename(filename: []const u8) BridgeError!void { | |
| 632 | if (filename.len == 0) { | |
| 633 | std.log.warn("saveFile refused: filename is empty", .{}); | |
| 634 | return BridgeError.InvalidParameter; | |
| 635 | } | |
| 636 | if (std.mem.indexOfScalar(u8, filename, '/') != null) { | |
| 637 | std.log.warn("saveFile refused: filename contains a path separator", .{}); | |
| 638 | return BridgeError.InvalidParameter; | |
| 639 | } | |
| 640 | if (std.mem.indexOfScalar(u8, filename, 0) != null) { | |
| 641 | // A NUL would truncate the C string every native path API sees, so the | |
| 642 | // file written would not be the file named. | |
| 643 | std.log.warn("saveFile refused: filename contains a NUL byte", .{}); | |
| 644 | return BridgeError.InvalidParameter; | |
| 645 | } | |
| 646 | if (std.mem.eql(u8, filename, ".") or std.mem.eql(u8, filename, "..")) { | |
| 647 | std.log.warn("saveFile refused: filename is a directory reference", .{}); | |
| 648 | return BridgeError.InvalidParameter; | |
| 649 | } | |
| 650 | } | |
| 651 | ||
| 652 | /// The `data:` prefix Swift tests with `hasPrefix`. | |
| 653 | const data_url_prefix = "data:"; | |
| 654 | ||
| 655 | /// The bytes `saveFile` writes, decoded the way Swift decodes them — minus | |
| 656 | /// Swift's two silent no-write paths. | |
| 657 | /// | |
| 658 | /// `data:` prefixed: Swift splits on every `","` and requires exactly two parts, | |
| 659 | /// then base64-decodes the second. Both of those can fail, and when either does | |
| 660 | /// Swift writes nothing and *still resolves the path* — the page is told a file | |
| 661 | /// exists where nothing was written, or where a stale earlier file still sits. | |
| 662 | /// Both are `INVALID_PARAMETER` here. | |
| 663 | /// | |
| 664 | /// Not `data:` prefixed: the string is written as UTF-8 text, which for bytes | |
| 665 | /// already in hand is a copy. Both branches therefore reduce to "write these | |
| 666 | /// bytes", which is why there is one write path below and not two. | |
| 667 | /// | |
| 668 | /// `std.base64.standard.Decoder` has the same strictness as | |
| 669 | /// `Data(base64Encoded:)` with default options: standard alphabet, padding | |
| 670 | /// required, no non-alphabet characters tolerated. | |
| 671 | fn decodeSaveBytes(allocator: std.mem.Allocator, data: []const u8) ![]u8 { | |
| 672 | if (!std.mem.startsWith(u8, data, data_url_prefix)) { | |
| 673 | return allocator.dupe(u8, data); | |
| 674 | } | |
| 675 | ||
| 676 | if (std.mem.count(u8, data, ",") != 1) { | |
| 677 | std.log.warn( | |
| 678 | "saveFile refused: the data URL does not split into exactly two comma-separated " ++ | |
| 679 | "parts, so there is nothing to decode", | |
| 680 | .{}, | |
| 681 | ); | |
| 682 | return BridgeError.InvalidParameter; | |
| 683 | } | |
| 684 | ||
| 685 | const comma = std.mem.indexOfScalar(u8, data, ',').?; | |
| 686 | const encoded = data[comma + 1 ..]; | |
| 687 | ||
| 688 | const decoder = std.base64.standard.Decoder; | |
| 689 | const size = decoder.calcSizeForSlice(encoded) catch { | |
| 690 | std.log.warn("saveFile refused: the data URL payload is not valid base64", .{}); | |
| 691 | return BridgeError.InvalidParameter; | |
| 692 | }; | |
| 693 | ||
| 694 | const out = try allocator.alloc(u8, size); | |
| 695 | errdefer allocator.free(out); | |
| 696 | decoder.decode(out, encoded) catch { | |
| 697 | std.log.warn("saveFile refused: the data URL payload is not valid base64", .{}); | |
| 698 | return BridgeError.InvalidParameter; | |
| 699 | }; | |
| 700 | return out; | |
| 701 | } | |
| 702 | ||
| 703 | // ============================================================================= | |
| 704 | // Reply shaping. Pure, so the exact bytes the page receives are pinned by host | |
| 705 | // tests on every platform. | |
| 706 | // ============================================================================= | |
| 707 | ||
| 708 | /// One picked file, read out of the security-scoped URL. Every field is owned, | |
| 709 | /// including `mime_type` when it is the fallback, so freeing is unconditional. | |
| 710 | const PickedFile = struct { | |
| 711 | name: []u8, | |
| 712 | path: []u8, | |
| 713 | mime_type: []u8, | |
| 714 | /// The base64 body alone. The `data:` prefix is added by `shapePickedFile`, | |
| 715 | /// so the bytes are stored once rather than concatenated twice. | |
| 716 | base64: []u8, | |
| 717 | ||
| 718 | fn deinit(self: PickedFile, allocator: std.mem.Allocator) void { | |
| 719 | allocator.free(self.name); | |
| 720 | allocator.free(self.path); | |
| 721 | allocator.free(self.mime_type); | |
| 722 | allocator.free(self.base64); | |
| 723 | } | |
| 724 | }; | |
| 725 | ||
| 726 | /// Swift's fallback when a filename extension maps to no type | |
| 727 | /// (`CraftApp.swift:5081-5083`), used for both the `mimeType` key and the | |
| 728 | /// `data:` prefix, exactly as Swift uses one string for both. | |
| 729 | const default_mime_type = "application/octet-stream"; | |
| 730 | ||
| 731 | /// The four-key object `documentPicker(_:didPickDocumentsAt:)` resolves. | |
| 732 | /// | |
| 733 | /// Key order is `name`, `path`, `data`, `mimeType` — Swift's literal order. | |
| 734 | /// Swift's own is a `Dictionary` and therefore arbitrary, so no caller can | |
| 735 | /// depend on it, but a test can only pin bytes that are deterministic. | |
| 736 | /// | |
| 737 | /// `name`, `path` and `mime_type` are JSON-escaped: filenames are user | |
| 738 | /// controlled and legitimately contain `"` and `\`. The base64 body is appended | |
| 739 | /// raw, because the standard alphabet plus `=` needs no escaping — and that is | |
| 740 | /// *checked* rather than asserted, by the scan below, so a body that somehow | |
| 741 | /// carried anything else is a refusal instead of broken JavaScript in the page. | |
| 742 | fn shapePickedFile(allocator: std.mem.Allocator, file: PickedFile) ![]u8 { | |
| 743 | if (!isStandardBase64(file.base64)) { | |
| 744 | // `warn`, not `err`: this is a refusal, which is how every other | |
| 745 | // refusal in the mobile modules is logged, and the host test below | |
| 746 | // drives it on purpose. | |
| 747 | std.log.warn( | |
| 748 | "pickFile: the encoded body carries a byte outside the base64 alphabet; " ++ | |
| 749 | "refusing rather than emitting a reply that would not parse", | |
| 750 | .{}, | |
| 751 | ); | |
| 752 | return BridgeError.NativeCallFailed; | |
| 753 | } | |
| 754 | ||
| 755 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 756 | errdefer out.deinit(allocator); | |
| 757 | ||
| 758 | try out.appendSlice(allocator, "{\"name\":\""); | |
| 759 | try bridge_error.appendJsonEscaped(allocator, &out, file.name); | |
| 760 | try out.appendSlice(allocator, "\",\"path\":\""); | |
| 761 | try bridge_error.appendJsonEscaped(allocator, &out, file.path); | |
| 762 | try out.appendSlice(allocator, "\",\"data\":\"data:"); | |
| 763 | try bridge_error.appendJsonEscaped(allocator, &out, file.mime_type); | |
| 764 | try out.appendSlice(allocator, ";base64,"); | |
| 765 | try out.appendSlice(allocator, file.base64); | |
| 766 | try out.appendSlice(allocator, "\",\"mimeType\":\""); | |
| 767 | try bridge_error.appendJsonEscaped(allocator, &out, file.mime_type); | |
| 768 | try out.appendSlice(allocator, "\"}"); | |
| 769 | ||
| 770 | return out.toOwnedSlice(allocator); | |
| 771 | } | |
| 772 | ||
| 773 | fn isStandardBase64(s: []const u8) bool { | |
| 774 | for (s) |c| switch (c) { | |
| 775 | 'A'...'Z', 'a'...'z', '0'...'9', '+', '/', '=' => {}, | |
| 776 | else => return false, | |
| 777 | }; | |
| 778 | return true; | |
| 779 | } | |
| 780 | ||
| 781 | /// `saveFile`'s reply: the written path as a **bare JSON string**, not an | |
| 782 | /// object. | |
| 783 | /// | |
| 784 | /// `resolveCallback` serialises with `.fragmentsAllowed`, so Swift's | |
| 785 | /// `result: fileURL.path` goes on the wire as a bare string, and Android agrees | |
| 786 | /// (`file.absolutePath`). An object here would be a shape nothing is written | |
| 787 | /// for. | |
| 788 | fn shapeSavedPath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { | |
| 789 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 790 | errdefer out.deinit(allocator); | |
| 791 | ||
| 792 | try out.append(allocator, '"'); | |
| 793 | try bridge_error.appendJsonEscaped(allocator, &out, path); | |
| 794 | try out.append(allocator, '"'); | |
| 795 | ||
| 796 | return out.toOwnedSlice(allocator); | |
| 797 | } | |
| 798 | ||
| 799 | // ============================================================================= | |
| 800 | // How a delegate callback settles the pending call. | |
| 801 | // | |
| 802 | // The resolve-versus-reject decision is rule 4's, so it lives in one pure | |
| 803 | // function rather than being spelled at each callback: `cancelled` mapping to | |
| 804 | // anything but `reject` is the single most likely way this file could go wrong, | |
| 805 | // and it is pinned by a host test. | |
| 806 | // ============================================================================= | |
| 807 | ||
| 808 | /// What a document-picker callback found. | |
| 809 | /// The error code each rejecting outcome deserves. | |
| 810 | /// | |
| 811 | /// The distinction was always here — `Outcome` separates a Cancel from an | |
| 812 | /// unreadable file — but there was no channel to carry it, so all four | |
| 813 | /// arrived as NATIVE_CALL_FAILED and the page could not tell "the user said | |
| 814 | /// no" from "something broke". `ios_async.deliverErrorCode` is that channel. | |
| 815 | /// | |
| 816 | /// `no_selection` and `dismissed` are cancels in every sense the page cares | |
| 817 | /// about: nothing was picked, and nothing went wrong. Only `unreadable` is a | |
| 818 | /// genuine native failure. | |
| 819 | fn errorFor(outcome: Outcome) bridge_error.BridgeError { | |
| 820 | return switch (outcome) { | |
| 821 | .cancelled, .no_selection, .dismissed => bridge_error.BridgeError.Cancelled, | |
| 822 | .unreadable => bridge_error.BridgeError.NativeCallFailed, | |
| 823 | .picked => unreachable, // not a rejecting outcome | |
| 824 | }; | |
| 825 | } | |
| 826 | ||
| 827 | const Outcome = enum { | |
| 828 | /// A file was chosen and its bytes were read. | |
| 829 | picked, | |
| 830 | /// The user tapped Cancel — `documentPickerWasCancelled:`. | |
| 831 | cancelled, | |
| 832 | /// `didPickDocumentsAtURLs:` with an empty array. | |
| 833 | no_selection, | |
| 834 | /// A file was chosen and something about reading or shaping it failed. | |
| 835 | unreadable, | |
| 836 | /// The sheet was swiped away — `presentationControllerDidDismiss:`, which | |
| 837 | /// arrives when `documentPickerWasCancelled:` does not. | |
| 838 | dismissed, | |
| 839 | }; | |
| 840 | ||
| 841 | /// How the page's promise must be settled. | |
| 842 | const Answer = enum { resolve, reject }; | |
| 843 | ||
| 844 | /// Swift's mapping, verbatim: only the read-succeeded branch resolves. | |
| 845 | /// | |
| 846 | /// `cancelled` is a **reject** (`rejectCallback(pendingCallbackId, error: "Cancelled")`), | |
| 847 | /// not a distinct resolve. `no_selection` is Swift's `"No file selected"` | |
| 848 | /// reject. `unreadable` is this file's divergence 2 — Swift resolves a | |
| 849 | /// data-less `{name, path}` there, which reports success for a file whose | |
| 850 | /// contents nobody read. `dismissed` is a swipe-down: nothing was picked, so | |
| 851 | /// resolving anything would be success for a pick that did not happen. | |
| 852 | fn answerFor(outcome: Outcome) Answer { | |
| 853 | return switch (outcome) { | |
| 854 | .picked => .resolve, | |
| 855 | .cancelled, .no_selection, .unreadable, .dismissed => .reject, | |
| 856 | }; | |
| 857 | } | |
| 858 | ||
| 859 | /// Settle whatever call this picker was serving, and nothing else. | |
| 860 | /// | |
| 861 | /// Takes the pending ticket, so a second callback — a late fire, a duplicate — | |
| 862 | /// finds nothing and is a no-op rather than a second reply. `json` is required | |
| 863 | /// for `.resolve` and ignored otherwise; a `.resolve` with no payload is | |
| 864 | /// downgraded to an error rather than resolved empty, because | |
| 865 | /// `craft-bridge.js` settles with `payload || {}` and an empty resolve would | |
| 866 | /// look like success. | |
| 867 | /// | |
| 868 | /// Never calls `evaluateJavaScript`: this runs on a framework callback, and the | |
| 869 | /// reply is main-thread-only. `ios_async` owns that hop. | |
| 870 | fn settlePick(outcome: Outcome, json: ?[]const u8) void { | |
| 871 | const ticket = takePendingPick() orelse { | |
| 872 | std.log.info( | |
| 873 | "pickFile: a document-picker callback arrived with no call waiting; ignored", | |
| 874 | .{}, | |
| 875 | ); | |
| 876 | return; | |
| 877 | }; | |
| 878 | ||
| 879 | switch (answerFor(outcome)) { | |
| 880 | .resolve => { | |
| 881 | const payload = json orelse { | |
| 882 | std.log.err("pickFile: a resolve was decided with no payload to send", .{}); | |
| 883 | ios_async.deliverError(ticket); | |
| 884 | return; | |
| 885 | }; | |
| 886 | ios_async.deliverJson(ticket, payload); | |
| 887 | }, | |
| 888 | .reject => { | |
| 889 | // Swift rejects with free text — "Cancelled", "No file selected" — | |
| 890 | // and `sendErrorToJS` carries an enum, so the exact wording is | |
| 891 | // still lost. What is no longer lost is the *kind*: `errorFor` | |
| 892 | // maps a Cancel to CANCELLED and only a genuine read failure to | |
| 893 | // NATIVE_CALL_FAILED, so a page can tell "the user said no" from | |
| 894 | // "something broke" without reading a log. | |
| 895 | ios_async.deliverErrorCode(ticket, errorFor(outcome)); | |
| 896 | }, | |
| 897 | } | |
| 898 | } | |
| 899 | ||
| 900 | // ============================================================================= | |
| 901 | // One pending pick, guarded. | |
| 902 | // | |
| 903 | // A picker is modal and serves exactly one call, so there is one slot rather | |
| 904 | // than a pool. It is mutex-guarded even though WebKit delivers the dispatch and | |
| 905 | // UIKit delivers the callbacks on the main thread, because "it should always be | |
| 906 | // the main thread" is not a guard. | |
| 907 | // ============================================================================= | |
| 908 | ||
| 909 | var pending_pick: ?ios_async.Ticket = null; | |
| 910 | var pick_mutex: compat_mutex.Mutex = .{}; | |
| 911 | ||
| 912 | /// Record the call the delegate will answer. False means one is already | |
| 913 | /// recorded, and the caller must refuse — never replace, which would strand the | |
| 914 | /// first caller's promise forever. | |
| 915 | fn publishPendingPick(ticket: ios_async.Ticket) bool { | |
| 916 | pick_mutex.lock(); | |
| 917 | defer pick_mutex.unlock(); | |
| 918 | if (pending_pick != null) return false; | |
| 919 | pending_pick = ticket; | |
| 920 | return true; | |
| 921 | } | |
| 922 | ||
| 923 | /// Read and clear the slot. Clearing is what makes a second callback a no-op | |
| 924 | /// rather than a second reply, and what lets the next `pickFile` through. | |
| 925 | fn takePendingPick() ?ios_async.Ticket { | |
| 926 | pick_mutex.lock(); | |
| 927 | defer pick_mutex.unlock(); | |
| 928 | const ticket = pending_pick; | |
| 929 | pending_pick = null; | |
| 930 | return ticket; | |
| 931 | } | |
| 932 | ||
| 933 | // ============================================================================= | |
| 934 | // The delegate class, built once through `ios_delegate`. | |
| 935 | // ============================================================================= | |
| 936 | ||
| 937 | /// Deliberately prefixed, following `bridge_mobile_location.zig`'s | |
| 938 | /// `CraftIOSLocationDelegate`: both this and any desktop registration look a | |
| 939 | /// class up with `objc_getClass` first, so a shared name would let whichever | |
| 940 | /// registered second silently adopt the other's IMPs. | |
| 941 | const delegate_class_name = "CraftIOSDocumentPickerDelegate"; | |
| 942 | ||
| 943 | /// The plural, iOS 11+ selector. The deprecated singular | |
| 944 | /// `documentPicker:didPickDocumentAtURL:` is deliberately not registered — | |
| 945 | /// UIKit calls the plural, and registering both would give two answers to one | |
| 946 | /// pick on any OS that still called the old one. | |
| 947 | const sel_did_pick_documents = "documentPicker:didPickDocumentsAtURLs:"; | |
| 948 | const sel_was_cancelled = "documentPickerWasCancelled:"; | |
| 949 | /// The swipe-down belt, registered on the same delegate. iOS 13+; on anything | |
| 950 | /// older it is never sent. See `attachDismissObserver`. | |
| 951 | const sel_did_dismiss = "presentationControllerDidDismiss:"; | |
| 952 | ||
| 953 | /// The delegate instance, held for the life of the process. | |
| 954 | /// | |
| 955 | /// `UIDocumentPickerViewController.h:65` declares `delegate` **weak**, and | |
| 956 | /// `ios_delegate.instantiate` does not retain. This var is the strong | |
| 957 | /// reference; without it the first Cancel is a use-after-free. | |
| 958 | var delegate_instance: Id = null; | |
| 959 | ||
| 960 | fn ensureDelegate() !Id { | |
| 961 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 962 | if (delegate_instance) |existing| return existing; | |
| 963 | ||
| 964 | // Plain `fn`, not `export fn`: an exported symbol is process-global and | |
| 965 | // these names are generic enough to collide with another delegate in the | |
| 966 | // same binary. `class_addMethod` takes the address either way. | |
| 967 | const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &.{ | |
| 968 | .{ | |
| 969 | .selector = sel_did_pick_documents, | |
| 970 | .imp = @ptrCast(&documentPickerDidPickDocumentsAtURLs), | |
| 971 | // `- (void)m:(id)controller :(id)urls`. A wrong encoding still | |
| 972 | // registers and still dispatches, then reads arguments from the | |
| 973 | // wrong registers with no crash and no compile error, which is why | |
| 974 | // the shape is named rather than spelled. | |
| 975 | .types = ios_delegate.enc.void_two_objects, | |
| 976 | }, | |
| 977 | .{ | |
| 978 | .selector = sel_was_cancelled, | |
| 979 | .imp = @ptrCast(&documentPickerWasCancelled), | |
| 980 | // `- (void)m:(id)controller`. | |
| 981 | .types = ios_delegate.enc.void_one_object, | |
| 982 | }, | |
| 983 | .{ | |
| 984 | // `- (void)presentationControllerDidDismiss:(UIPresentationController *)pc` | |
| 985 | .selector = sel_did_dismiss, | |
| 986 | .imp = @ptrCast(&documentPickerDidDismissInteractively), | |
| 987 | .types = ios_delegate.enc.void_one_object, | |
| 988 | }, | |
| 989 | }); | |
| 990 | ||
| 991 | const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed; | |
| 992 | delegate_instance = instance; | |
| 993 | return instance; | |
| 994 | } | |
| 995 | ||
| 996 | /// Dismiss the picker, but only if it is still presented. | |
| 997 | /// | |
| 998 | /// `UIDocumentPickerViewController` dismisses itself before calling the | |
| 999 | /// delegate — Swift's two methods contain no `dismiss` call, unlike the image | |
| 1000 | /// picker's. An unconditional dismiss would therefore be a second one, and | |
| 1001 | /// `-dismissViewControllerAnimated:completion:` on a controller with nothing | |
| 1002 | /// presented forwards up the presentation chain and can close something nobody | |
| 1003 | /// asked to close. Reading `presentingViewController` first is what makes this | |
| 1004 | /// safe in both worlds: nil means it has already gone, non-nil means it is | |
| 1005 | /// genuinely still up and the app would otherwise be stuck behind it. | |
| 1006 | fn dismissPicker(controller: Id) void { | |
| 1007 | if (!is_darwin) return; | |
| 1008 | if (controller == null) return; | |
| 1009 | ||
| 1010 | const sel_presenting = objc.sel_registerName("presentingViewController") orelse return; | |
| 1011 | if (objc.msgSendId(controller, sel_presenting) == null) return; | |
| 1012 | ||
| 1013 | const sel_dismiss = objc.sel_registerName("dismissViewControllerAnimated:completion:") orelse { | |
| 1014 | std.log.warn( | |
| 1015 | "pickFile: no dismissViewControllerAnimated:completion: selector; the document " ++ | |
| 1016 | "picker is still presented", | |
| 1017 | .{}, | |
| 1018 | ); | |
| 1019 | return; | |
| 1020 | }; | |
| 1021 | const DismissFn = *const fn (Id, Id, bool, Id) callconv(.c) void; | |
| 1022 | const dismiss: DismissFn = @ptrCast(&objc.objc_msgSend); | |
| 1023 | dismiss(controller, sel_dismiss, true, null); | |
| 1024 | } | |
| 1025 | ||
| 1026 | fn documentPickerDidPickDocumentsAtURLs(_: Id, _: Id, controller: Id, urls: Id) callconv(.c) void { | |
| 1027 | if (!is_darwin) return; | |
| 1028 | ||
| 1029 | dismissPicker(controller); | |
| 1030 | ||
| 1031 | const allocator = std.heap.c_allocator; | |
| 1032 | ||
| 1033 | const sel_first = objc.sel_registerName("firstObject") orelse { | |
| 1034 | std.log.err("pickFile: no firstObject selector; cannot read the picked URL", .{}); | |
| 1035 | settlePick(.unreadable, null); | |
| 1036 | return; | |
| 1037 | }; | |
| 1038 | // `urls.first` in Swift. `firstObject` answers nil for an empty array, | |
| 1039 | // where `objectAtIndex:0` would raise an uncatchable NSRangeException. | |
| 1040 | const url = objc.msgSendId(urls, sel_first) orelse { | |
| 1041 | std.log.warn("pickFile: the document picker returned no URL", .{}); | |
| 1042 | settlePick(.no_selection, null); | |
| 1043 | return; | |
| 1044 | }; | |
| 1045 | ||
| 1046 | const file = readPickedFile(allocator, url) catch |err| { | |
| 1047 | std.log.err( | |
| 1048 | "pickFile: could not read the picked file ({}); rejecting rather than resolving " ++ | |
| 1049 | "an object with no data, which is what the Swift path does here", | |
| 1050 | .{err}, | |
| 1051 | ); | |
| 1052 | settlePick(.unreadable, null); | |
| 1053 | return; | |
| 1054 | }; | |
| 1055 | defer file.deinit(allocator); | |
| 1056 | ||
| 1057 | const json = shapePickedFile(allocator, file) catch |err| { | |
| 1058 | std.log.err("pickFile: could not shape the reply ({})", .{err}); | |
| 1059 | settlePick(.unreadable, null); | |
| 1060 | return; | |
| 1061 | }; | |
| 1062 | defer allocator.free(json); | |
| 1063 | ||
| 1064 | settlePick(.picked, json); | |
| 1065 | } | |
| 1066 | ||
| 1067 | fn documentPickerWasCancelled(_: Id, _: Id, controller: Id) callconv(.c) void { | |
| 1068 | if (!is_darwin) return; | |
| 1069 | ||
| 1070 | dismissPicker(controller); | |
| 1071 | ||
| 1072 | // Swift's `rejectCallback(pendingCallbackId, error: "Cancelled")`. The word | |
| 1073 | // survives only here — the wire carries a `BridgeError` enum. | |
| 1074 | std.log.info("pickFile: cancelled by the user", .{}); | |
| 1075 | settlePick(.cancelled, null); | |
| 1076 | } | |
| 1077 | ||
| 1078 | /// The user swiped the sheet away. | |
| 1079 | /// | |
| 1080 | /// Nothing is dismissed here: UIKit has already taken the sheet off the screen, | |
| 1081 | /// which is exactly what this callback reports, and sending | |
| 1082 | /// `dismissViewControllerAnimated:` now could close whatever the presenter shows | |
| 1083 | /// next. The only job is to settle the call. | |
| 1084 | /// | |
| 1085 | /// Reached with the slot already empty whenever a real callback got there first, | |
| 1086 | /// in which case `settlePick` is a no-op rather than a second answer. | |
| 1087 | fn documentPickerDidDismissInteractively(_: Id, _: Id, _: Id) callconv(.c) void { | |
| 1088 | if (!is_darwin) return; | |
| 1089 | ||
| 1090 | std.log.warn( | |
| 1091 | "pickFile: the picker was dismissed interactively; rejecting the call rather than " ++ | |
| 1092 | "leaving it unanswered on a promise that has no timeout", | |
| 1093 | .{}, | |
| 1094 | ); | |
| 1095 | settlePick(.dismissed, null); | |
| 1096 | } | |
| 1097 | ||
| 1098 | /// The three selectors the swipe-down belt needs, resolved at dispatch so a | |
| 1099 | /// missing one fails the call rather than silently leaving the only thing that | |
| 1100 | /// answers an interactive dismissal unattached. | |
| 1101 | const DismissSels = struct { | |
| 1102 | /// `-[UIViewController presentationController]`. | |
| 1103 | presentation: Id, | |
| 1104 | /// `-[UIPresentationController delegate]`, read so an existing delegate is | |
| 1105 | /// never displaced. | |
| 1106 | delegate: Id, | |
| 1107 | /// `-[UIPresentationController setDelegate:]` — the same `setDelegate:` the | |
| 1108 | /// picker's own delegate is installed with. | |
| 1109 | set_delegate: Id, | |
| 1110 | }; | |
| 1111 | ||
| 1112 | /// Point the picker's presentation controller at this module's delegate, so an | |
| 1113 | /// interactive dismissal reaches `presentationControllerDidDismiss:`. Returns | |
| 1114 | /// whether it was attached. | |
| 1115 | /// | |
| 1116 | /// `UIDocumentPickerViewController` presents as a form/page sheet, and a | |
| 1117 | /// swipe-down is widely reported not to send `documentPickerWasCancelled:`. | |
| 1118 | /// That report is verified on no device in this repo, so this is a belt and not | |
| 1119 | /// a fix: it costs two messages, and if the report is right it is the only | |
| 1120 | /// thing that ever frees `pending_pick` after a swipe. It matters more here than | |
| 1121 | /// in Swift, because this module *refuses* later calls while one is pending — a | |
| 1122 | /// stranded ticket disables `pickFile` for the life of the process, where Swift | |
| 1123 | /// merely strands the one promise. It is also the same belt | |
| 1124 | /// `bridge_mobile_imagepicker.zig` and `bridge_mobile_contactpicker.zig` carry. | |
| 1125 | /// | |
| 1126 | /// A presentation controller that already carries a delegate is left alone and | |
| 1127 | /// reported as not attached: displacing one would change how the picker manages | |
| 1128 | /// its own adaptive presentation, which is a new bug in exchange for a belt. | |
| 1129 | /// | |
| 1130 | /// Idempotent against the real cancel. UIKit sends | |
| 1131 | /// `presentationControllerDidDismiss:` only for a user-driven dismissal, never | |
| 1132 | /// for a programmatic one, so it cannot follow a pick or a Cancel that already | |
| 1133 | /// answered — and if it did anyway, `takePendingPick` has already emptied the | |
| 1134 | /// slot and `ios_async`'s generation counter would drop the second delivery. | |
| 1135 | fn attachDismissObserver(picker: Id, delegate: Id, sels: DismissSels) bool { | |
| 1136 | if (!is_darwin) return false; | |
| 1137 | ||
| 1138 | const presentation = objc.msgSendId(picker, sels.presentation) orelse return false; | |
| 1139 | if (objc.msgSendId(presentation, sels.delegate) != null) return false; | |
| 1140 | ||
| 1141 | objc.msgSendVoid1(presentation, sels.set_delegate, delegate); | |
| 1142 | return true; | |
| 1143 | } | |
| 1144 | ||
| 1145 | // ============================================================================= | |
| 1146 | // Reading the picked file. | |
| 1147 | // ============================================================================= | |
| 1148 | ||
| 1149 | /// Read one security-scoped document URL into the four fields the reply needs. | |
| 1150 | /// | |
| 1151 | /// The `start`/`stop` pair around the read is the fix described as divergence 1 | |
| 1152 | /// in the module comment: the picker was built with `asCopy == NO`, so this URL | |
| 1153 | /// points outside the app container and the read fails on a device without it. | |
| 1154 | /// It is invisible in the simulator, where the URL resolves to an ordinary host | |
| 1155 | /// path and the read succeeds either way — so a green simulator run says | |
| 1156 | /// nothing about this call. | |
| 1157 | fn readPickedFile(allocator: std.mem.Allocator, url: Id) !PickedFile { | |
| 1158 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1159 | ||
| 1160 | const sel_start = try selector("startAccessingSecurityScopedResource"); | |
| 1161 | const sel_stop = try selector("stopAccessingSecurityScopedResource"); | |
| 1162 | const sel_last_component = try selector("lastPathComponent"); | |
| 1163 | const sel_path = try selector("path"); | |
| 1164 | const sel_extension = try selector("pathExtension"); | |
| 1165 | const sel_data_with_url = try selector("dataWithContentsOfURL:"); | |
| 1166 | const sel_base64 = try selector("base64EncodedStringWithOptions:"); | |
| 1167 | ||
| 1168 | // BOOL, and the return matters: the stop must be sent only when the start | |
| 1169 | // succeeded, because the access counter is balanced. | |
| 1170 | const scoped = objc.msgSendBool(url, sel_start); | |
| 1171 | defer if (scoped) objc.msgSend(url, sel_stop); | |
| 1172 | ||
| 1173 | const name_obj = objc.msgSendId(url, sel_last_component) orelse return error.NativeCallFailed; | |
| 1174 | const name = try copyNSString(allocator, name_obj); | |
| 1175 | errdefer allocator.free(name); | |
| 1176 | ||
| 1177 | const path_obj = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed; | |
| 1178 | const path = try copyNSString(allocator, path_obj); | |
| 1179 | errdefer allocator.free(path); | |
| 1180 | ||
| 1181 | const extension_obj = objc.msgSendId(url, sel_extension); | |
| 1182 | const mime_type = try copyMimeType(allocator, extension_obj); | |
| 1183 | errdefer allocator.free(mime_type); | |
| 1184 | ||
| 1185 | const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound; | |
| 1186 | // Nil here is Swift's `try?` yielding nil, which is where it resolves an | |
| 1187 | // object with no `data`. This is the branch that fails on a device without | |
| 1188 | // the security scope above. | |
| 1189 | const data_obj = objc.msgSendId1(NSData, sel_data_with_url, url) orelse | |
| 1190 | return error.FileContentsUnreadable; | |
| 1191 | ||
| 1192 | // Options 0 — exactly `Data.base64EncodedString()`: standard alphabet, | |
| 1193 | // padded, no line wrapping. | |
| 1194 | const base64_obj = objc.msgSendId1(data_obj, sel_base64, @as(c_ulong, 0)) orelse | |
| 1195 | return error.NativeCallFailed; | |
| 1196 | const base64 = try copyNSString(allocator, base64_obj); | |
| 1197 | ||
| 1198 | return .{ .name = name, .path = path, .mime_type = mime_type, .base64 = base64 }; | |
| 1199 | } | |
| 1200 | ||
| 1201 | /// `URL.mimeType` from `CraftApp.swift:5078-5085`: the filename extension's | |
| 1202 | /// preferred MIME type, or `application/octet-stream`. | |
| 1203 | /// | |
| 1204 | /// A nil at either step is Swift's fallback and not an error — a file with no | |
| 1205 | /// extension, or an extension no type claims, is an ordinary file. The `UTType` | |
| 1206 | /// class *is* looked up again here and a null answer is an error rather than a | |
| 1207 | /// fallback, which is unreachable in practice (`pickFile` refused before | |
| 1208 | /// presenting if the class was absent) but is a guard rather than an assumption. | |
| 1209 | fn copyMimeType(allocator: std.mem.Allocator, extension_obj: Id) ![]u8 { | |
| 1210 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1211 | ||
| 1212 | const fallback = try allocator.dupe(u8, default_mime_type); | |
| 1213 | errdefer allocator.free(fallback); | |
| 1214 | ||
| 1215 | if (extension_obj == null) return fallback; | |
| 1216 | ||
| 1217 | const UTTypeClass = objc.objc_getClass("UTType") orelse return error.ClassNotFound; | |
| 1218 | const sel_with_extension = try selector("typeWithFilenameExtension:"); | |
| 1219 | const sel_preferred = try selector("preferredMIMEType"); | |
| 1220 | ||
| 1221 | const ut_type = objc.msgSendId1(UTTypeClass, sel_with_extension, extension_obj) orelse | |
| 1222 | return fallback; | |
| 1223 | const mime_obj = objc.msgSendId(ut_type, sel_preferred) orelse return fallback; | |
| 1224 | ||
| 1225 | const mime = try copyNSString(allocator, mime_obj); | |
| 1226 | allocator.free(fallback); | |
| 1227 | return mime; | |
| 1228 | } | |
| 1229 | ||
| 1230 | /// The `UTType` array `initForOpeningContentTypes:` takes. | |
| 1231 | /// | |
| 1232 | /// Swift's default is `[.item]`, the `UTTypeItem` global. It is built here with | |
| 1233 | /// `+[UTType typeWithIdentifier:@"public.item"]` rather than by `dlsym`-ing the | |
| 1234 | /// `UTType *const` cell: the identifier is documented and stable, and it does | |
| 1235 | /// not depend on the symbol being exported by whatever image loaded the | |
| 1236 | /// framework. | |
| 1237 | /// | |
| 1238 | /// Each page-supplied entry is mapped MIME type first and filename extension | |
| 1239 | /// second, as Swift does, and then by identifier — see divergence 4. An entry | |
| 1240 | /// that maps to nothing is refused rather than dropped. | |
| 1241 | /// | |
| 1242 | /// `parsePickRequest` refuses an empty `types` array, so the returned array | |
| 1243 | /// always has at least one element. | |
| 1244 | fn buildContentTypes(allocator: std.mem.Allocator, request: PickRequest, UTTypeClass: Id) !Id { | |
| 1245 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1246 | ||
| 1247 | const NSMutableArray = objc.objc_getClass("NSMutableArray") orelse return error.ClassNotFound; | |
| 1248 | const sel_array = try selector("array"); | |
| 1249 | const sel_add = try selector("addObject:"); | |
| 1250 | const sel_with_identifier = try selector("typeWithIdentifier:"); | |
| 1251 | const sel_with_mime = try selector("typeWithMIMEType:"); | |
| 1252 | const sel_with_extension = try selector("typeWithFilenameExtension:"); | |
| 1253 | ||
| 1254 | const types = objc.msgSendId(NSMutableArray, sel_array) orelse return error.NativeCallFailed; | |
| 1255 | ||
| 1256 | const listed = request.types orelse { | |
| 1257 | const ns_item = try objc.createNSString("public.item", allocator); | |
| 1258 | if (ns_item == null) return error.NativeCallFailed; | |
| 1259 | const item = objc.msgSendId1(UTTypeClass, sel_with_identifier, ns_item) orelse { | |
| 1260 | std.log.err("pickFile: UTType has no public.item; cannot build the default", .{}); | |
| 1261 | return BridgeError.NativeCallFailed; | |
| 1262 | }; | |
| 1263 | objc.msgSendVoid1(types, sel_add, item); | |
| 1264 | return types; | |
| 1265 | }; | |
| 1266 | ||
| 1267 | for (listed) |entry| { | |
| 1268 | const ns_entry = try objc.createNSString(entry, allocator); | |
| 1269 | if (ns_entry == null) { | |
| 1270 | std.log.warn("pickFile refused: a types entry is not valid UTF-8", .{}); | |
| 1271 | return BridgeError.InvalidParameter; | |
| 1272 | } | |
| 1273 | ||
| 1274 | const mapped = | |
| 1275 | objc.msgSendId1(UTTypeClass, sel_with_mime, ns_entry) orelse | |
| 1276 | objc.msgSendId1(UTTypeClass, sel_with_extension, ns_entry) orelse | |
| 1277 | objc.msgSendId1(UTTypeClass, sel_with_identifier, ns_entry) orelse | |
| 1278 | { | |
| 1279 | // Swift's `compactMap` drops this silently and can end up with | |
| 1280 | // an empty array; dropping a field the page sent is exactly | |
| 1281 | // what must not happen. | |
| 1282 | std.log.warn( | |
| 1283 | "pickFile refused: types entry '{s}' is not a MIME type, a filename " ++ | |
| 1284 | "extension, or a type identifier", | |
| 1285 | .{entry}, | |
| 1286 | ); | |
| 1287 | return BridgeError.InvalidParameter; | |
| 1288 | }; | |
| 1289 | ||
| 1290 | // `-[NSMutableArray addObject:]` raises NSInvalidArgumentException on | |
| 1291 | // nil, which from Zig is an uncatchable crash rather than something the | |
| 1292 | // page can be told about. `mapped` is non-null by construction above. | |
| 1293 | objc.msgSendVoid1(types, sel_add, mapped); | |
| 1294 | } | |
| 1295 | ||
| 1296 | return types; | |
| 1297 | } | |
| 1298 | ||
| 1299 | // ============================================================================= | |
| 1300 | // The Documents directory and the write. Both are the third copy of a chain | |
| 1301 | // `bridge_mobile_db.zig` and `bridge_mobile_locrecording.zig` already carry | |
| 1302 | // file-privately; extracting the shared helper is a change to those two files | |
| 1303 | // and is not made here. | |
| 1304 | // ============================================================================= | |
| 1305 | ||
| 1306 | /// `NSDocumentDirectory`. | |
| 1307 | const ns_document_directory: c_ulong = 9; | |
| 1308 | /// `NSUserDomainMask`. | |
| 1309 | const ns_user_domain_mask: c_ulong = 1; | |
| 1310 | /// `NSUTF8StringEncoding`. | |
| 1311 | const ns_utf8_string_encoding: c_ulong = 4; | |
| 1312 | /// `NSDataWritingAtomic`. | |
| 1313 | const ns_data_writing_atomic: c_ulong = 1; | |
| 1314 | ||
| 1315 | /// `NSFileManager` -> `URLsForDirectory:inDomains:` -> `firstObject` -> `path`, | |
| 1316 | /// the chain Swift's `saveFile` runs, with every step guarded. | |
| 1317 | /// | |
| 1318 | /// Swift subscripts with `[0]`, which raises `NSRangeException` on an empty | |
| 1319 | /// result; `firstObject` answers nil, and nil here is a named refusal rather | |
| 1320 | /// than an uncatchable SIGABRT. | |
| 1321 | fn documentsPath(allocator: std.mem.Allocator) ![]u8 { | |
| 1322 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1323 | ||
| 1324 | const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound; | |
| 1325 | const sel_default = try selector("defaultManager"); | |
| 1326 | const manager = objc.msgSendId(NSFileManager, sel_default) orelse return error.NativeCallFailed; | |
| 1327 | ||
| 1328 | const sel_urls = try selector("URLsForDirectory:inDomains:"); | |
| 1329 | // NSUInteger arguments: explicit `c_ulong`, because the variadic msgSend | |
| 1330 | // cast takes its argument types from what is passed. | |
| 1331 | const urls = objc.msgSendId2( | |
| 1332 | manager, | |
| 1333 | sel_urls, | |
| 1334 | ns_document_directory, | |
| 1335 | ns_user_domain_mask, | |
| 1336 | ) orelse return error.NativeCallFailed; | |
| 1337 | ||
| 1338 | const sel_first = try selector("firstObject"); | |
| 1339 | const url = objc.msgSendId(urls, sel_first) orelse return error.NotFound; | |
| 1340 | ||
| 1341 | const sel_path = try selector("path"); | |
| 1342 | const path_obj = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed; | |
| 1343 | ||
| 1344 | return copyNSString(allocator, path_obj); | |
| 1345 | } | |
| 1346 | ||
| 1347 | /// Write `bytes` to `path`, atomically. | |
| 1348 | /// | |
| 1349 | /// Swift's two branches differ — the base64 one uses `Data.write(to:)` with | |
| 1350 | /// default options (non-atomic) and the text one | |
| 1351 | /// `String.write(to:atomically:true,encoding:.utf8)`. Both are atomic here; the | |
| 1352 | /// difference is observable only in that a crash mid-write leaves the previous | |
| 1353 | /// file intact rather than a truncated one, which is the direction worth being | |
| 1354 | /// wrong in. | |
| 1355 | /// | |
| 1356 | /// `writeToFile:options:error:` rather than `writeToFile:atomically:`, purely so | |
| 1357 | /// the `NSError` can be logged: the page's reply carries a `BridgeError` enum | |
| 1358 | /// and the description has nowhere else to go. | |
| 1359 | fn writeFileAtomically(allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void { | |
| 1360 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1361 | ||
| 1362 | const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound; | |
| 1363 | const sel_with_bytes = try selector("dataWithBytes:length:"); | |
| 1364 | const sel_write = try selector("writeToFile:options:error:"); | |
| 1365 | ||
| 1366 | const data_obj = objc.msgSendId2( | |
| 1367 | NSData, | |
| 1368 | sel_with_bytes, | |
| 1369 | bytes.ptr, | |
| 1370 | @as(c_ulong, bytes.len), | |
| 1371 | ) orelse return error.NativeCallFailed; | |
| 1372 | ||
| 1373 | const ns_path = try objc.createNSString(path, allocator); | |
| 1374 | if (ns_path == null) return error.NativeCallFailed; | |
| 1375 | ||
| 1376 | var write_error: Id = null; | |
| 1377 | const WriteFn = *const fn (Id, Id, Id, c_ulong, *Id) callconv(.c) bool; | |
| 1378 | const write_fn: WriteFn = @ptrCast(&objc.objc_msgSend); | |
| 1379 | const wrote = write_fn(data_obj, sel_write, ns_path, ns_data_writing_atomic, &write_error); | |
| 1380 | ||
| 1381 | if (!wrote) { | |
| 1382 | std.log.warn( | |
| 1383 | "saveFile: could not write '{s}': {s}", | |
| 1384 | .{ path, readNSString(write_error, "localizedDescription") orelse "(no description)" }, | |
| 1385 | ); | |
| 1386 | return BridgeError.NativeCallFailed; | |
| 1387 | } | |
| 1388 | } | |
| 1389 | ||
| 1390 | /// An `NSString` as owned bytes, refusing a value `UTF8String` truncated. | |
| 1391 | /// | |
| 1392 | /// `-[NSString UTF8String]` is NUL-terminated, so a name or path carrying an | |
| 1393 | /// embedded U+0000 would read back short — and a short path names a *different* | |
| 1394 | /// file. The C-string length is therefore checked against | |
| 1395 | /// `lengthOfBytesUsingEncoding:`, the same check | |
| 1396 | /// `bridge_mobile_locrecording.zig` and `bridge_mobile_notifications.zig` make. | |
| 1397 | fn copyNSString(allocator: std.mem.Allocator, ns_string: Id) ![]u8 { | |
| 1398 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1399 | ||
| 1400 | const cstr = objc.getNSStringUTF8(ns_string) orelse return error.NativeCallFailed; | |
| 1401 | const bytes = std.mem.span(cstr); | |
| 1402 | ||
| 1403 | const sel_len = try selector("lengthOfBytesUsingEncoding:"); | |
| 1404 | const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong; | |
| 1405 | const len_fn: LenFn = @ptrCast(&objc.objc_msgSend); | |
| 1406 | const declared = len_fn(ns_string, sel_len, ns_utf8_string_encoding); | |
| 1407 | ||
| 1408 | if (declared != bytes.len) { | |
| 1409 | std.log.warn( | |
| 1410 | "file picker: a string is {d} UTF-8 bytes but reads back as {d}; refusing rather " ++ | |
| 1411 | "than reporting a truncated name or path", | |
| 1412 | .{ declared, bytes.len }, | |
| 1413 | ); | |
| 1414 | return error.NativeCallFailed; | |
| 1415 | } | |
| 1416 | ||
| 1417 | return allocator.dupe(u8, bytes); | |
| 1418 | } | |
| 1419 | ||
| 1420 | /// A zero-argument `NSString`-returning property, as borrowed bytes. Null for a | |
| 1421 | /// nil object, a nil string, or a selector that will not register — all three | |
| 1422 | /// are "nothing to report". The slice borrows the string's buffer, valid for the | |
| 1423 | /// current autorelease pool; the only caller logs it synchronously. | |
| 1424 | fn readNSString(object: Id, comptime name: [*:0]const u8) ?[]const u8 { | |
| 1425 | if (!is_darwin) return null; | |
| 1426 | if (object == null) return null; | |
| 1427 | ||
| 1428 | const sel = objc.sel_registerName(name) orelse return null; | |
| 1429 | const value = objc.msgSendId(object, sel) orelse return null; | |
| 1430 | const utf8 = objc.getNSStringUTF8(value) orelse return null; | |
| 1431 | return std.mem.span(utf8); | |
| 1432 | } | |
| 1433 | ||
| 1434 | // ============================================================================= | |
| 1435 | // Tests - host-only. | |
| 1436 | // | |
| 1437 | // Everything that decides page-visible bytes is pure and pinned here: routing | |
| 1438 | // in both directions, both payloads field by field, the resolve/reject decision | |
| 1439 | // for all four outcomes, the four-key success object and the bare-string save | |
| 1440 | // reply, the base64 and filename guards, and the concurrent-pick refusal. | |
| 1441 | // | |
| 1442 | // Nothing here presents a picker or touches the Documents directory. On a macOS | |
| 1443 | // runner `objc_getClass("UIDocumentPickerViewController")` is null — UIKit is | |
| 1444 | // not loadable there at all — so the UIKit paths are unreachable rather than | |
| 1445 | // merely skipped, and the fixture cannot drive a modal either (it posts raw | |
| 1446 | // envelopes with no way to tap Cancel or choose a file). `saveFile` is the half | |
| 1447 | // that a simulator fixture can drive end to end. | |
| 1448 | // ============================================================================= | |
| 1449 | ||
| 1450 | const testing = std.testing; | |
| 1451 | ||
| 1452 | test "the declared actions are the ones the handler serves" { | |
| 1453 | try testing.expectEqual(@as(usize, 2), capability_actions.len); | |
| 1454 | try testing.expectEqualStrings(A.pick_file, capability_actions[0].name); | |
| 1455 | try testing.expectEqualStrings(A.save_file, capability_actions[1].name); | |
| 1456 | ||
| 1457 | for (capability_actions) |decl| { | |
| 1458 | // A `.result` whose handler never replies parks the caller until its | |
| 1459 | // timeout; a `.none` that is awaited resolves immediately and means | |
| 1460 | // nothing. Swift resolves or rejects both of these. | |
| 1461 | try testing.expectEqual(capabilities.Reply.result, decl.reply); | |
| 1462 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); | |
| 1463 | // `.live` with a reason would be a contradiction the manifest shows apps. | |
| 1464 | try testing.expect(decl.reason == null); | |
| 1465 | } | |
| 1466 | } | |
| 1467 | ||
| 1468 | test "the action names match the Swift case labels exactly" { | |
| 1469 | try testing.expectEqualStrings("pickFile", A.pick_file); | |
| 1470 | try testing.expectEqualStrings("saveFile", A.save_file); | |
| 1471 | } | |
| 1472 | ||
| 1473 | test "every declared action is one the dispatcher routes" { | |
| 1474 | for (capability_actions) |decl| { | |
| 1475 | if (routeFor(decl.name) == null) { | |
| 1476 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 1477 | return error.DeclaredActionDoesNotRoute; | |
| 1478 | } | |
| 1479 | } | |
| 1480 | } | |
| 1481 | ||
| 1482 | test "every route the dispatcher has is a declared action" { | |
| 1483 | // The other direction: a route with a handler and no declaration is an | |
| 1484 | // action the page can call and the manifest denies exists. Each declaration | |
| 1485 | // must claim a *distinct* route — counting alone would let two rows share | |
| 1486 | // one route while another went undeclared. | |
| 1487 | var claimed = std.mem.zeroes([std.enums.values(Route).len]bool); | |
| 1488 | for (capability_actions) |decl| { | |
| 1489 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 1490 | const slot = @backingInt(route); | |
| 1491 | if (claimed[slot]) { | |
| 1492 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 1493 | return error.TwoDeclarationsShareARoute; | |
| 1494 | } | |
| 1495 | claimed[slot] = true; | |
| 1496 | } | |
| 1497 | for (claimed, 0..) |taken, slot| { | |
| 1498 | if (!taken) { | |
| 1499 | std.debug.print( | |
| 1500 | "route {s} has a handler but no capability_actions row\n", | |
| 1501 | .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))}, | |
| 1502 | ); | |
| 1503 | return error.RouteNotDeclared; | |
| 1504 | } | |
| 1505 | } | |
| 1506 | } | |
| 1507 | ||
| 1508 | test "the dispatcher reaches both handlers, not just the routing table" { | |
| 1509 | // The other half of table-versus-dispatch: `routeFor` agreeing with | |
| 1510 | // `capability_actions` proves nothing if `handleMessage` never calls the | |
| 1511 | // handler. Both payloads fail early and for their own reason — `saveFile` | |
| 1512 | // has no `data` field, and `pickFile` finds no UIKit picker class on a | |
| 1513 | // macOS host — so this is a routing check and not a device test. What it | |
| 1514 | // rules out is the one silent failure: `UnknownAction`, which | |
| 1515 | // `ios_dispatch.route` reads as "not mine" and hands to the Swift shim. | |
| 1516 | var bridge = FilePickerBridge.init(testing.allocator); | |
| 1517 | defer bridge.deinit(); | |
| 1518 | ||
| 1519 | for (capability_actions) |decl| { | |
| 1520 | bridge.handleMessage(decl.name, "{}") catch |err| { | |
| 1521 | try testing.expect(err != BridgeError.UnknownAction); | |
| 1522 | continue; | |
| 1523 | }; | |
| 1524 | // Neither can succeed here: `saveFile` is missing its required fields | |
| 1525 | // and `pickFile` has no picker to present. | |
| 1526 | std.debug.print("'{s}' unexpectedly succeeded on a host\n", .{decl.name}); | |
| 1527 | return error.HandlerSucceededWithoutADevice; | |
| 1528 | } | |
| 1529 | } | |
| 1530 | ||
| 1531 | test "an action the namespace does not serve is reported, not ignored" { | |
| 1532 | var bridge = FilePickerBridge.init(testing.allocator); | |
| 1533 | defer bridge.deinit(); | |
| 1534 | ||
| 1535 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("noSuchAction", "{}")); | |
| 1536 | ||
| 1537 | // Casing is how a real typo arrives, and a miss does not fail loudly: | |
| 1538 | // `ios_dispatch` reads UnknownAction as "not mine" and hands the action to | |
| 1539 | // the Swift shim, so a typo would silently un-serve the action. | |
| 1540 | for ([_][]const u8{ "pickfile", "PickFile", "savefile", "SaveFile" }) |typo| { | |
| 1541 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(typo, "{}")); | |
| 1542 | } | |
| 1543 | ||
| 1544 | // The desktop dialog namespace has its own `saveFile`; it is a different | |
| 1545 | // action reached through a different `t`, and nothing about it belongs here. | |
| 1546 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("showSaveDialog", "{}")); | |
| 1547 | } | |
| 1548 | ||
| 1549 | test "downloadFile is left with the shim, and left undeclared" { | |
| 1550 | var bridge = FilePickerBridge.init(testing.allocator); | |
| 1551 | defer bridge.deinit(); | |
| 1552 | ||
| 1553 | for (deliberately_unserved) |name| { | |
| 1554 | // `UnknownAction` is the one return `ios_dispatch.route` turns into a | |
| 1555 | // Swift-shim hand-off. Anything else would take the action away from | |
| 1556 | // the arm that answers it today. | |
| 1557 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(name, "{}")); | |
| 1558 | ||
| 1559 | for (capability_actions) |decl| { | |
| 1560 | try testing.expect(!std.mem.eql(u8, decl.name, name)); | |
| 1561 | } | |
| 1562 | try testing.expect(routeFor(name) == null); | |
| 1563 | } | |
| 1564 | } | |
| 1565 | ||
| 1566 | // ----------------------------------------------------------------------------- | |
| 1567 | // pickFile payload. | |
| 1568 | // ----------------------------------------------------------------------------- | |
| 1569 | ||
| 1570 | test "pickFile with no types asks for the default single item type" { | |
| 1571 | // `body[\"types\"] as? [String]` is nil, and Swift's `allowedTypes` stays | |
| 1572 | // `[.item]`. Null must behave the same as absent, because that is what the | |
| 1573 | // cast does with a JSON null. | |
| 1574 | for ([_][]const u8{ "{}", "{\"types\":null}" }) |payload| { | |
| 1575 | const request = try parsePickRequest(testing.allocator, payload); | |
| 1576 | defer request.deinit(testing.allocator); | |
| 1577 | try testing.expect(request.types == null); | |
| 1578 | } | |
| 1579 | } | |
| 1580 | ||
| 1581 | test "pickFile carries every types entry the page sent, in order" { | |
| 1582 | const request = try parsePickRequest( | |
| 1583 | testing.allocator, | |
| 1584 | "{\"types\":[\"image/png\",\"pdf\",\"public.image\"]}", | |
| 1585 | ); | |
| 1586 | defer request.deinit(testing.allocator); | |
| 1587 | ||
| 1588 | const listed = request.types orelse return error.TypesWereDropped; | |
| 1589 | try testing.expectEqual(@as(usize, 3), listed.len); | |
| 1590 | try testing.expectEqualStrings("image/png", listed[0]); | |
| 1591 | try testing.expectEqualStrings("pdf", listed[1]); | |
| 1592 | try testing.expectEqualStrings("public.image", listed[2]); | |
| 1593 | } | |
| 1594 | ||
| 1595 | test "pickFile refuses a types field it cannot use rather than dropping it" { | |
| 1596 | // An empty array is what Swift's compactMap can produce, and what | |
| 1597 | // `initForOpeningContentTypes:` does with one is unverified. A non-array | |
| 1598 | // and a non-string entry are payloads no page should send, and silently | |
| 1599 | // treating any of them as the default would widen the types the app asked | |
| 1600 | // to restrict. | |
| 1601 | for ([_][]const u8{ | |
| 1602 | "{\"types\":[]}", | |
| 1603 | "{\"types\":\"image/png\"}", | |
| 1604 | "{\"types\":42}", | |
| 1605 | "{\"types\":[\"image/png\",7]}", | |
| 1606 | "{\"types\":[null]}", | |
| 1607 | }) |payload| { | |
| 1608 | try testing.expectError( | |
| 1609 | BridgeError.InvalidParameter, | |
| 1610 | parsePickRequest(testing.allocator, payload), | |
| 1611 | ); | |
| 1612 | } | |
| 1613 | } | |
| 1614 | ||
| 1615 | test "pickFile refuses a payload that is not a JSON object" { | |
| 1616 | for ([_][]const u8{ "[]", "\"types\"", "not json" }) |payload| { | |
| 1617 | try testing.expectError( | |
| 1618 | BridgeError.InvalidJSON, | |
| 1619 | parsePickRequest(testing.allocator, payload), | |
| 1620 | ); | |
| 1621 | } | |
| 1622 | } | |
| 1623 | ||
| 1624 | // ----------------------------------------------------------------------------- | |
| 1625 | // saveFile payload. | |
| 1626 | // ----------------------------------------------------------------------------- | |
| 1627 | ||
| 1628 | /// Parse `json` and hand its root object to `parseSaveRequest`. The request | |
| 1629 | /// borrows the parsed value, so the caller frees them together. | |
| 1630 | fn saveRequestFrom(json: []const u8) !struct { | |
| 1631 | parsed: std.json.Parsed(std.json.Value), | |
| 1632 | request: SaveRequest, | |
| 1633 | ||
| 1634 | fn deinit(self: *@This()) void { | |
| 1635 | self.parsed.deinit(); | |
| 1636 | } | |
| 1637 | } { | |
| 1638 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1639 | errdefer parsed.deinit(); | |
| 1640 | return .{ .parsed = parsed, .request = try parseSaveRequest(parsed.value.object) }; | |
| 1641 | } | |
| 1642 | ||
| 1643 | test "saveFile reads data and filename, and nothing else" { | |
| 1644 | var got = try saveRequestFrom( | |
| 1645 | "{\"data\":\"hello\",\"filename\":\"notes.txt\",\"mimeType\":\"text/plain\"}", | |
| 1646 | ); | |
| 1647 | defer got.deinit(); | |
| 1648 | ||
| 1649 | try testing.expectEqualStrings("hello", got.request.data); | |
| 1650 | try testing.expectEqualStrings("notes.txt", got.request.filename); | |
| 1651 | // `mimeType` is declared by `craft.d.ts` and passed by the README, and read | |
| 1652 | // by neither Swift nor Android. Accepting and ignoring it is the only shape | |
| 1653 | // that neither invents a field nor breaks the documented call. | |
| 1654 | } | |
| 1655 | ||
| 1656 | test "saveFile refuses a payload missing either required field" { | |
| 1657 | const parsed_a = try std.json.parseFromSlice( | |
| 1658 | std.json.Value, | |
| 1659 | testing.allocator, | |
| 1660 | "{\"filename\":\"a.txt\"}", | |
| 1661 | .{}, | |
| 1662 | ); | |
| 1663 | defer parsed_a.deinit(); | |
| 1664 | try testing.expectError(BridgeError.MissingData, parseSaveRequest(parsed_a.value.object)); | |
| 1665 | ||
| 1666 | const parsed_b = try std.json.parseFromSlice( | |
| 1667 | std.json.Value, | |
| 1668 | testing.allocator, | |
| 1669 | "{\"data\":\"hello\"}", | |
| 1670 | .{}, | |
| 1671 | ); | |
| 1672 | defer parsed_b.deinit(); | |
| 1673 | try testing.expectError(BridgeError.MissingData, parseSaveRequest(parsed_b.value.object)); | |
| 1674 | } | |
| 1675 | ||
| 1676 | test "saveFile refuses a data or filename of the wrong type" { | |
| 1677 | for ([_][]const u8{ | |
| 1678 | "{\"data\":7,\"filename\":\"a.txt\"}", | |
| 1679 | "{\"data\":\"hello\",\"filename\":7}", | |
| 1680 | "{\"data\":null,\"filename\":\"a.txt\"}", | |
| 1681 | }) |payload| { | |
| 1682 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{}); | |
| 1683 | defer parsed.deinit(); | |
| 1684 | try testing.expectError( | |
| 1685 | BridgeError.InvalidParameter, | |
| 1686 | parseSaveRequest(parsed.value.object), | |
| 1687 | ); | |
| 1688 | } | |
| 1689 | } | |
| 1690 | ||
| 1691 | test "a filename that would leave the Documents directory is refused" { | |
| 1692 | // Swift joins this string on with appendingPathComponent and the write | |
| 1693 | // lands wherever it points. | |
| 1694 | for ([_][]const u8{ | |
| 1695 | "", | |
| 1696 | "..", | |
| 1697 | ".", | |
| 1698 | "../Library/Preferences/x.plist", | |
| 1699 | "sub/dir.txt", | |
| 1700 | "/etc/passwd", | |
| 1701 | "a\x00b.txt", | |
| 1702 | }) |name| { | |
| 1703 | try testing.expectError(BridgeError.InvalidParameter, validateFilename(name)); | |
| 1704 | } | |
| 1705 | } | |
| 1706 | ||
| 1707 | test "an ordinary filename with dots in it is not refused" { | |
| 1708 | // The traversal check must not be a substring match on "..": a name with no | |
| 1709 | // separator cannot name a parent directory whatever dots it contains. | |
| 1710 | for ([_][]const u8{ "notes.txt", "my..notes.txt", "..hidden", "archive.tar.gz", "a" }) |name| { | |
| 1711 | try validateFilename(name); | |
| 1712 | } | |
| 1713 | } | |
| 1714 | ||
| 1715 | // ----------------------------------------------------------------------------- | |
| 1716 | // saveFile content decoding. | |
| 1717 | // ----------------------------------------------------------------------------- | |
| 1718 | ||
| 1719 | test "a plain string is written as its own bytes" { | |
| 1720 | const bytes = try decodeSaveBytes(testing.allocator, "hello, world"); | |
| 1721 | defer testing.allocator.free(bytes); | |
| 1722 | try testing.expectEqualStrings("hello, world", bytes); | |
| 1723 | } | |
| 1724 | ||
| 1725 | test "a base64 data URL is decoded to the bytes it encodes" { | |
| 1726 | const bytes = try decodeSaveBytes(testing.allocator, "data:text/plain;base64,aGVsbG8="); | |
| 1727 | defer testing.allocator.free(bytes); | |
| 1728 | try testing.expectEqualStrings("hello", bytes); | |
| 1729 | } | |
| 1730 | ||
| 1731 | test "an empty base64 body decodes to an empty file, as Swift writes one" { | |
| 1732 | const bytes = try decodeSaveBytes(testing.allocator, "data:,"); | |
| 1733 | defer testing.allocator.free(bytes); | |
| 1734 | try testing.expectEqual(@as(usize, 0), bytes.len); | |
| 1735 | } | |
| 1736 | ||
| 1737 | test "a data URL that cannot be decoded is refused, never reported as written" { | |
| 1738 | // Swift reaches `resolveCallback(callbackId, result: fileURL.path)` for | |
| 1739 | // every one of these having written nothing: the `if` body just never runs | |
| 1740 | // and nothing throws. `data:text/plain,hello` is the documented reproducer. | |
| 1741 | for ([_][]const u8{ | |
| 1742 | "data:text/plain,hello", | |
| 1743 | "data:", | |
| 1744 | "data:text/plain;base64", | |
| 1745 | "data:a,b,c", | |
| 1746 | "data:text/plain;base64,aGVsbG8", | |
| 1747 | "data:text/plain;base64,!!!!", | |
| 1748 | }) |payload| { | |
| 1749 | try testing.expectError( | |
| 1750 | BridgeError.InvalidParameter, | |
| 1751 | decodeSaveBytes(testing.allocator, payload), | |
| 1752 | ); | |
| 1753 | } | |
| 1754 | } | |
| 1755 | ||
| 1756 | test "a string that merely contains data: is not treated as a data URL" { | |
| 1757 | // `hasPrefix`, not `contains`. | |
| 1758 | const bytes = try decodeSaveBytes(testing.allocator, "see data:text/plain,hello"); | |
| 1759 | defer testing.allocator.free(bytes); | |
| 1760 | try testing.expectEqualStrings("see data:text/plain,hello", bytes); | |
| 1761 | } | |
| 1762 | ||
| 1763 | // ----------------------------------------------------------------------------- | |
| 1764 | // Reply shaping. | |
| 1765 | // ----------------------------------------------------------------------------- | |
| 1766 | ||
| 1767 | test "a picked file is the four-key object Swift resolves" { | |
| 1768 | const file = PickedFile{ | |
| 1769 | .name = @constCast("report.pdf"), | |
| 1770 | .path = @constCast("/private/var/mobile/report.pdf"), | |
| 1771 | .mime_type = @constCast("application/pdf"), | |
| 1772 | .base64 = @constCast("aGVsbG8="), | |
| 1773 | }; | |
| 1774 | ||
| 1775 | const json = try shapePickedFile(testing.allocator, file); | |
| 1776 | defer testing.allocator.free(json); | |
| 1777 | ||
| 1778 | try testing.expectEqualStrings( | |
| 1779 | "{\"name\":\"report.pdf\",\"path\":\"/private/var/mobile/report.pdf\"," ++ | |
| 1780 | "\"data\":\"data:application/pdf;base64,aGVsbG8=\",\"mimeType\":\"application/pdf\"}", | |
| 1781 | json, | |
| 1782 | ); | |
| 1783 | ||
| 1784 | // And it is valid JSON with the keys the page reads, not just string-equal. | |
| 1785 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1786 | defer parsed.deinit(); | |
| 1787 | try testing.expectEqualStrings("report.pdf", parsed.value.object.get("name").?.string); | |
| 1788 | try testing.expectEqualStrings( | |
| 1789 | "data:application/pdf;base64,aGVsbG8=", | |
| 1790 | parsed.value.object.get("data").?.string, | |
| 1791 | ); | |
| 1792 | } | |
| 1793 | ||
| 1794 | test "a filename carrying quotes and backslashes stays valid JSON" { | |
| 1795 | // Filenames are user controlled and legitimately contain both. An | |
| 1796 | // unescaped quote would close the string early and turn the whole reply | |
| 1797 | // script into a syntax error in the page, with nothing to point at. | |
| 1798 | const file = PickedFile{ | |
| 1799 | .name = @constCast("we\"ird\\name.txt"), | |
| 1800 | .path = @constCast("/tmp/we\"ird\\name.txt"), | |
| 1801 | .mime_type = @constCast("text/plain"), | |
| 1802 | .base64 = @constCast("QQ=="), | |
| 1803 | }; | |
| 1804 | ||
| 1805 | const json = try shapePickedFile(testing.allocator, file); | |
| 1806 | defer testing.allocator.free(json); | |
| 1807 | ||
| 1808 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1809 | defer parsed.deinit(); | |
| 1810 | try testing.expectEqualStrings("we\"ird\\name.txt", parsed.value.object.get("name").?.string); | |
| 1811 | try testing.expectEqualStrings("/tmp/we\"ird\\name.txt", parsed.value.object.get("path").?.string); | |
| 1812 | } | |
| 1813 | ||
| 1814 | test "an encoded body outside the base64 alphabet is refused, not emitted raw" { | |
| 1815 | // The body is appended without escaping because the standard alphabet needs | |
| 1816 | // none. This is the check that makes that claim true rather than assumed. | |
| 1817 | const file = PickedFile{ | |
| 1818 | .name = @constCast("a.txt"), | |
| 1819 | .path = @constCast("/tmp/a.txt"), | |
| 1820 | .mime_type = @constCast("text/plain"), | |
| 1821 | .base64 = @constCast("abc\"def"), | |
| 1822 | }; | |
| 1823 | try testing.expectError( | |
| 1824 | BridgeError.NativeCallFailed, | |
| 1825 | shapePickedFile(testing.allocator, file), | |
| 1826 | ); | |
| 1827 | } | |
| 1828 | ||
| 1829 | test "the saved path goes back as a bare JSON string, not an object" { | |
| 1830 | // `resolveCallback` serialises with .fragmentsAllowed and Swift resolves | |
| 1831 | // `fileURL.path`. An object here would be a shape nothing reads. | |
| 1832 | const json = try shapeSavedPath(testing.allocator, "/var/mobile/Documents/notes.txt"); | |
| 1833 | defer testing.allocator.free(json); | |
| 1834 | ||
| 1835 | try testing.expectEqualStrings("\"/var/mobile/Documents/notes.txt\"", json); | |
| 1836 | ||
| 1837 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1838 | defer parsed.deinit(); | |
| 1839 | try testing.expect(parsed.value == .string); | |
| 1840 | ||
| 1841 | // And it is truthy: `craft-bridge.js` settles with `payload || {}`, so an | |
| 1842 | // empty reply would arrive as `{}`. A container path is never empty. | |
| 1843 | try testing.expect(parsed.value.string.len > 0); | |
| 1844 | } | |
| 1845 | ||
| 1846 | test "a path needing escaping survives the bare-string reply" { | |
| 1847 | const json = try shapeSavedPath(testing.allocator, "/tmp/we\"ird\\path.txt"); | |
| 1848 | defer testing.allocator.free(json); | |
| 1849 | ||
| 1850 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1851 | defer parsed.deinit(); | |
| 1852 | try testing.expectEqualStrings("/tmp/we\"ird\\path.txt", parsed.value.string); | |
| 1853 | } | |
| 1854 | ||
| 1855 | // ----------------------------------------------------------------------------- | |
| 1856 | // How a callback settles the call. | |
| 1857 | // ----------------------------------------------------------------------------- | |
| 1858 | ||
| 1859 | test "a cancel is a rejection, and only a read that succeeded resolves" { | |
| 1860 | // The single most likely way this file could go wrong, and the one thing | |
| 1861 | // that cannot be read off a picker in a test: Swift's | |
| 1862 | // `documentPickerWasCancelled:` calls `rejectCallback(…, \"Cancelled\")`, | |
| 1863 | // not a resolve, and an empty `urls` array is its `\"No file selected\"` | |
| 1864 | // reject. | |
| 1865 | try testing.expectEqual(Answer.resolve, answerFor(.picked)); | |
| 1866 | try testing.expectEqual(Answer.reject, answerFor(.cancelled)); | |
| 1867 | try testing.expectEqual(Answer.reject, answerFor(.no_selection)); | |
| 1868 | // Divergence 2: Swift resolves `{name, path}` here, without the `data` its | |
| 1869 | // own type declares required. | |
| 1870 | try testing.expectEqual(Answer.reject, answerFor(.unreadable)); | |
| 1871 | // The swipe-down belt. Nothing was picked, so a resolve would be success | |
| 1872 | // for a pick that did not happen. | |
| 1873 | try testing.expectEqual(Answer.reject, answerFor(.dismissed)); | |
| 1874 | ||
| 1875 | // And every outcome is covered: a new one added without a row above would | |
| 1876 | // otherwise be pinned by nothing. | |
| 1877 | try testing.expectEqual(@as(usize, 5), std.enums.values(Outcome).len); | |
| 1878 | } | |
| 1879 | ||
| 1880 | test "the swipe-down belt is registered, and only the answer paths reject" { | |
| 1881 | // Without `presentationControllerDidDismiss:` on the delegate, a sheet the | |
| 1882 | // user swipes away leaves `pending_pick` set: the call is never answered | |
| 1883 | // *and* every later pickFile is refused for the life of the process. Two | |
| 1884 | // sibling picker modules carry the same belt. | |
| 1885 | try testing.expectEqualStrings("presentationControllerDidDismiss:", sel_did_dismiss); | |
| 1886 | // `- (void)m:(id)presentationController` — one object, not two. | |
| 1887 | try testing.expectEqualStrings("v@:@", ios_delegate.enc.void_one_object); | |
| 1888 | } | |
| 1889 | ||
| 1890 | test "the two delegate selectors are the plural pair, with the encodings UIKit calls" { | |
| 1891 | // A wrong encoding still registers and still dispatches, then reads | |
| 1892 | // arguments from the wrong registers with no crash and no compile error. | |
| 1893 | // Naming the shapes is the only defence available, so the pairing is pinned. | |
| 1894 | try testing.expectEqualStrings("documentPicker:didPickDocumentsAtURLs:", sel_did_pick_documents); | |
| 1895 | try testing.expectEqualStrings("documentPickerWasCancelled:", sel_was_cancelled); | |
| 1896 | try testing.expectEqualStrings("v@:@@", ios_delegate.enc.void_two_objects); | |
| 1897 | try testing.expectEqualStrings("v@:@", ios_delegate.enc.void_one_object); | |
| 1898 | ||
| 1899 | // And the deprecated singular is not one of them: registering it would give | |
| 1900 | // two answers to one pick on any OS that still called it. | |
| 1901 | try testing.expect(!std.mem.eql(u8, sel_did_pick_documents, "documentPicker:didPickDocumentAtURL:")); | |
| 1902 | } | |
| 1903 | ||
| 1904 | test "the delegate class name is this module's own" { | |
| 1905 | // Both this and any desktop registration look a class up with | |
| 1906 | // `objc_getClass` first, so a shared name would let whichever registered | |
| 1907 | // second silently adopt the other's IMPs. | |
| 1908 | try testing.expectEqualStrings("CraftIOSDocumentPickerDelegate", delegate_class_name); | |
| 1909 | } | |
| 1910 | ||
| 1911 | // ----------------------------------------------------------------------------- | |
| 1912 | // One pending pick. | |
| 1913 | // ----------------------------------------------------------------------------- | |
| 1914 | ||
| 1915 | test "a second pick while one is presented is refused, never a silent replacement" { | |
| 1916 | // Swift overwrites its single `pendingCallbackId` and the first caller's | |
| 1917 | // promise never settles. The refusal is what makes every caller settle. | |
| 1918 | try testing.expect(takePendingPick() == null); | |
| 1919 | ||
| 1920 | const first = ios_async.acquire(A.pick_file) orelse return error.PoolUnexpectedlyFull; | |
| 1921 | defer ios_async.abandon(first); | |
| 1922 | try testing.expect(publishPendingPick(first)); | |
| 1923 | ||
| 1924 | const second = ios_async.acquire(A.pick_file) orelse return error.PoolUnexpectedlyFull; | |
| 1925 | defer ios_async.abandon(second); | |
| 1926 | try testing.expect(!publishPendingPick(second)); | |
| 1927 | ||
| 1928 | // The first ticket is still the one a callback would answer — not the | |
| 1929 | // second, and not nothing. | |
| 1930 | const held = takePendingPick() orelse return error.PendingPickWasLost; | |
| 1931 | try testing.expectEqual(first.index, held.index); | |
| 1932 | try testing.expectEqual(first.generation, held.generation); | |
| 1933 | ||
| 1934 | // With the slot free, the next pick takes it. | |
| 1935 | try testing.expect(publishPendingPick(second)); | |
| 1936 | try testing.expect(takePendingPick() != null); | |
| 1937 | try testing.expect(takePendingPick() == null); | |
| 1938 | } | |
| 1939 | ||
| 1940 | test "a callback with nothing pending is ignored rather than replying twice" { | |
| 1941 | // A late fire, or a duplicate: `takePendingPick` cleared the slot on the | |
| 1942 | // first one, so the second finds nothing. The belt is the ordinary case | |
| 1943 | // here — UIKit can send `presentationControllerDidDismiss:` after a Cancel | |
| 1944 | // that already answered, and it must be a no-op, not a second reply. | |
| 1945 | try testing.expect(takePendingPick() == null); | |
| 1946 | settlePick(.cancelled, null); | |
| 1947 | settlePick(.dismissed, null); | |
| 1948 | settlePick(.picked, "{}"); | |
| 1949 | try testing.expect(takePendingPick() == null); | |
| 1950 | } | |
packages/zig/src/bridge_mobile_imagepicker.zigadded+1689-0
| @@ -0,0 +1,1689 @@ | ||
| 1 | //! `openCamera` and `pickImage` — the `mobile` namespace's two photo actions. | |
| 2 | //! | |
| 3 | //! They are one picker. Swift serves both from a single | |
| 4 | //! `UIImagePickerController` and a single delegate pair on `Coordinator` | |
| 5 | //! (`CraftApp.swift:376` declares `UIImagePickerControllerDelegate, | |
| 6 | //! UINavigationControllerDelegate`), and they differ in exactly one property: | |
| 7 | //! `sourceType`. The two delegate methods, the success shape, the cancel | |
| 8 | //! shape and the dismissal are shared. So this is one module, one delegate | |
| 9 | //! class, one instance, one pending ticket — and `Source` is both the action | |
| 10 | //! selector and the `UIImagePickerControllerSourceType` raw value. | |
| 11 | //! | |
| 12 | //! ## What crosses the bridge | |
| 13 | //! | |
| 14 | //! **Nothing.** Both injected methods are declared `function()` with zero | |
| 15 | //! parameters (`CraftApp.swift:1574-1590`), and the v1 surface drops its | |
| 16 | //! options on the floor too — `craft.camera.takePicture` is | |
| 17 | //! `function() { return legacyOpenCamera().then(normalizePhoto); }` at | |
| 18 | //! `:2343`, so the `CameraOptions` typed in | |
| 19 | //! `packages/typescript/src/api/mobile.ts:517` never reaches native on iOS. | |
| 20 | //! The Swift dispatcher reads nothing out of `body` either (`:554-563`). | |
| 21 | //! `data` is therefore accepted and ignored, on the | |
| 22 | //! `bridge_mobile_location.getCurrentPosition` precedent: parsing it would | |
| 23 | //! invent a failure mode the shim does not have. There is no field here to | |
| 24 | //! drop. | |
| 25 | //! | |
| 26 | //! ## What goes back | |
| 27 | //! | |
| 28 | //! **Success is a five-key object**, `CraftApp.swift:2647-2654`: | |
| 29 | //! | |
| 30 | //! ```json | |
| 31 | //! {"base64":"…","uri":"data:image/jpeg;base64,…","width":3024,"height":4032,"mimeType":"image/jpeg"} | |
| 32 | //! ``` | |
| 33 | //! | |
| 34 | //! `base64` is the raw encoding and `uri` is the same bytes again behind a | |
| 35 | //! `data:` prefix. The duplication is the contract — `normalizePhoto` | |
| 36 | //! (`:2417`) passes an object straight through onto `PhotoResult` | |
| 37 | //! (`mobile.ts:533-544`), which names all five keys — so it is reproduced | |
| 38 | //! rather than tidied. `mimeType` is the literal `"image/jpeg"` for both | |
| 39 | //! actions always, because the image is re-encoded to JPEG whatever the user | |
| 40 | //! picked. `width`/`height` are `-[UIImage size]` in **points**, and | |
| 41 | //! `JSONSerialization` renders an integral double with no fraction, which is | |
| 42 | //! what `{d}` does too. | |
| 43 | //! | |
| 44 | //! `test-bridges.html:513` reads the result as a string (`image?.length`) and | |
| 45 | //! has therefore printed `Base64 length: 0` against Swift for as long as this | |
| 46 | //! dictionary has existed. That is a pre-existing bug in the test page, not a | |
| 47 | //! reason to reply with a bare string: a string would break `craft.camera.*` | |
| 48 | //! and the SDK, which are written for the object. | |
| 49 | //! | |
| 50 | //! **Cancel is a rejection**, not a resolve and not a distinct result | |
| 51 | //! (`:2661-2665` — `rejectCallback(pendingCallbackId, error: "Cancelled")`). | |
| 52 | //! It has to be: `craft-bridge.js:70` resolves with `payload || {}`, so a | |
| 53 | //! `null` or `false` result would arrive at the page as `{}` and the app's | |
| 54 | //! `catch` would never run. | |
| 55 | //! | |
| 56 | //! ## The one thing this module cannot say | |
| 57 | //! | |
| 58 | //! Swift's cancel carries `code: "CRAFT_ERROR"` and `message: "Cancelled"`. | |
| 59 | //! `ios_async.deliverError` hardcodes `bridge_error.BridgeError.NativeCallFailed` | |
| 60 | //! (`ios_async.zig:258-265`), so the rejection the page receives here says | |
| 61 | //! `NATIVE_CALL_FAILED` / `"Native API call failed"`. The promise still | |
| 62 | //! rejects and the app's `catch` still runs — that half of the contract is | |
| 63 | //! kept — but the stated cause is wrong, and this comment is the only place | |
| 64 | //! that is allowed to say otherwise. | |
| 65 | //! | |
| 66 | //! Calling `bridge_error.sendErrorToJS` from the delegate callback instead is | |
| 67 | //! not an option: it ends in `evaluateJavaScript`, and by then the request id | |
| 68 | //! is gone anyway. The fix is a foundation change — | |
| 69 | //! `ios_async.deliverErrorCode(ticket, err)`, one extra field on `Slot` set | |
| 70 | //! under the same lock — and the document and contact pickers need the | |
| 71 | //! identical thing (`documentPickerWasCancelled` `:4912`, | |
| 72 | //! `contactPickerDidCancel` `:5102` both say `"Cancelled"`). It is recorded | |
| 73 | //! here rather than worked around. | |
| 74 | //! | |
| 75 | //! ## The config gate, and exactly what it stands in for | |
| 76 | //! | |
| 77 | //! Swift wraps both actions in `if config.enableCamera`, which defaults to | |
| 78 | //! **false** (`CraftApp.swift:192`, `packages/ios/src/index.ts:120`). When it | |
| 79 | //! is false the `case` falls off the end and replies *nothing at all*, while | |
| 80 | //! `CraftSwiftShim.handleAction` still returns `true` (`:5247`) so Zig | |
| 81 | //! believes the hand-off succeeded. The injected promise is the hand-built | |
| 82 | //! kind with no timeout. **In a default-config app, `openCamera` and | |
| 83 | //! `pickImage` hang the page forever today.** The "working Swift shim" is | |
| 84 | //! only working for an app that set `enableCamera: true`. | |
| 85 | //! | |
| 86 | //! `enableCamera` has no Zig-reachable channel, so this module gates on the | |
| 87 | //! Info.plist keys `packages/ios/src/index.ts:185-186` writes: | |
| 88 | //! | |
| 89 | //! - **`NSCameraUsageDescription` is an extra gate on `openCamera` only, and | |
| 90 | //! it is a crash guard rather than a config proxy.** Presenting a `.camera` picker in a | |
| 91 | //! process without that key is a TCC termination, not an error anything | |
| 92 | //! could be told about. It is written for | |
| 93 | //! `enableCamera || enableVideoRecording || enableQRScanner || enableAR`. | |
| 94 | //! - **`NSPhotoLibraryUsageDescription` stands in for `config.enableCamera`, | |
| 95 | //! and it is over-broad by `enableVideoRecording`** — it is written for | |
| 96 | //! `enableCamera || enableVideoRecording`, which is the closest proxy | |
| 97 | //! available. It is emphatically **not** a photo-library permission gate: | |
| 98 | //! `UIImagePickerController` with `.photoLibrary` has run out-of-process | |
| 99 | //! since iOS 11 and neither prompts nor requires the key. Refusing on it is | |
| 100 | //! a statement about how the app was configured, never about what the | |
| 101 | //! system would allow. | |
| 102 | //! | |
| 103 | //! So `openCamera` requires *both* keys and `pickImage` requires only the | |
| 104 | //! photo-library one. | |
| 105 | //! | |
| 106 | //! Measured against the shim, that trades three ways. An `enableCamera: true` | |
| 107 | //! app behaves exactly as it does now. A default-config app gets an explicit | |
| 108 | //! `PERMISSION_DENIED` instead of a promise that never settles — strictly | |
| 109 | //! better. And exactly one configuration, `enableVideoRecording: true` with | |
| 110 | //! `enableCamera: false`, gains a capability Swift denies it; that one is a | |
| 111 | //! real widening, it runs in the direction the Info.plist already sanctions | |
| 112 | //! (the usage strings exist, so the user sees a prompt the app declared), and | |
| 113 | //! the cell it widens is otherwise an infinite hang. The alternative was to | |
| 114 | //! leave both actions out of `A` entirely and keep the hang, which is a trade | |
| 115 | //! rather than a free choice. | |
| 116 | //! | |
| 117 | //! `.unavailable` was never on the table: a declared-`.unavailable` action | |
| 118 | //! dispatches and refuses, which would take the working `enableCamera: true` | |
| 119 | //! path away from the shim. `bridge_mobile_misc.zig`'s "Why unserved rather | |
| 120 | //! than `.unavailable`" is the precedent. | |
| 121 | //! | |
| 122 | //! ## Simulator | |
| 123 | //! | |
| 124 | //! `+[UIImagePickerController isSourceTypeAvailable:]` answers `NO` for | |
| 125 | //! `.camera` on every simulator. Presenting anyway shows nothing and the | |
| 126 | //! delegate never fires — a promise that hangs, the single worst outcome — so | |
| 127 | //! `openCamera` asks first and refuses synchronously, before any slot is | |
| 128 | //! leased, exactly as Swift does at `:2616-2619`. `pickImage` has no such | |
| 129 | //! guard in Swift and gets none here: `.photoLibrary` is available on | |
| 130 | //! simulator and device alike, and an empty library still presents a picker | |
| 131 | //! whose only move is Cancel. | |
| 132 | //! | |
| 133 | //! ## One picker, one caller | |
| 134 | //! | |
| 135 | //! A modal serves exactly one call, so there is one `pending` for the module | |
| 136 | //! rather than one per action — both actions present the same kind of sheet | |
| 137 | //! and only one can be up. A second request while one is presented is refused | |
| 138 | //! **for the second caller**, with the first left alone. That is the opposite | |
| 139 | //! of `bridge_mobile_location.getCurrentPosition`, which rejects the | |
| 140 | //! *displaced first* call: there `requestLocation` genuinely cancels the | |
| 141 | //! earlier request so it can never be answered, while here the first picker is | |
| 142 | //! still on screen and will answer. Replacing it would strand its promise | |
| 143 | //! forever. | |
| 144 | //! | |
| 145 | //! The cost of refusing rather than displacing is that a `pending` which is | |
| 146 | //! never taken refuses every later call for the life of the process. Three | |
| 147 | //! callbacks can take it — the pick, the cancel, and | |
| 148 | //! `presentationControllerDidDismiss:` for the swipe-down that has been widely | |
| 149 | //! reported not to fire `imagePickerControllerDidCancel:` since iOS 13. That | |
| 150 | //! third one is a belt, not a proof: if a presentation controller cannot be | |
| 151 | //! reached the module says so in the log and does **not** claim that a cancel | |
| 152 | //! is always delivered. | |
| 153 | //! | |
| 154 | //! ## A Swift bug this deliberately does not inherit | |
| 155 | //! | |
| 156 | //! `startVideoRecording` (`:3731-3749`) reuses the *same* `Coordinator` | |
| 157 | //! delegate and the *same* `pendingCallbackId`, sets `mediaTypes` to | |
| 158 | //! `["public.movie"]`, and then `didFinishPickingMediaWithInfo` reads | |
| 159 | //! `info[.originalImage]` — nil for a movie — so every finished recording | |
| 160 | //! rejects with `"Failed to process image"`. It stays with the shim; because | |
| 161 | //! this module has its own delegate class and its own instance, the crosstalk | |
| 162 | //! disappears rather than getting worse. | |
| 163 | ||
| 164 | const std = ; | |
| 165 | const builtin = ; | |
| 166 | const capabilities = ; | |
| 167 | const bridge_error = ; | |
| 168 | const objc_runtime = ; | |
| 169 | const ios_async = ; | |
| 170 | const ios_delegate = ; | |
| 171 | const bridge_mobile_system = ; | |
| 172 | const compat_mutex = ; | |
| 173 | ||
| 174 | const objc = objc_runtime.objc; | |
| 175 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 176 | ||
| 177 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the | |
| 178 | /// location/motion precedent: `objc_runtime.objc` is an empty struct off | |
| 179 | /// Darwin and a `callconv(.c)` fn-pointer *type* is analysed even when a | |
| 180 | /// comptime platform guard prunes the body around it. A single optional | |
| 181 | /// pointer, never `?objc.id`: a double optional is illegal in `callconv(.c)`. | |
| 182 | const Id = ?*anyopaque; | |
| 183 | ||
| 184 | /// The action names, spelled exactly as the Swift `case` labels spell them. | |
| 185 | /// `test/ios_conformance_test.zig` matches this block against those labels in | |
| 186 | /// both directions and fails the build if two modules declare one name. | |
| 187 | pub const A = struct { | |
| 188 | pub const open_camera = "openCamera"; | |
| 189 | pub const pick_image = "pickImage"; | |
| 190 | }; | |
| 191 | ||
| 192 | /// `.result`: each Swift path terminates in one `resolveCallback` (the pick) | |
| 193 | /// or one `rejectCallback` (the cancel, or an image that would not encode), | |
| 194 | /// and the page's promise is the hand-built kind with no timeout — `.none` | |
| 195 | /// here would park a caller forever rather than for thirty seconds. | |
| 196 | /// | |
| 197 | /// `.live`: it dispatches and presents. A refusal is a specific failure — the | |
| 198 | /// app was not built with the camera enabled, no camera on this device, the | |
| 199 | /// picker is already up — never the action's normal answer, which is what | |
| 200 | /// `.unavailable` would declare. | |
| 201 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 202 | .{ .name = A.open_camera, .reply = .result }, | |
| 203 | .{ .name = A.pick_image, .reply = .result }, | |
| 204 | }; | |
| 205 | ||
| 206 | /// Which picker an action selects — and, because the values are chosen to be, | |
| 207 | /// the `UIImagePickerControllerSourceType` to set. | |
| 208 | /// | |
| 209 | /// `NSInteger`, hence `c_long`: a narrower type passes 0 and 1 correctly by | |
| 210 | /// luck and breaks the day the enum grows. | |
| 211 | /// `UIImagePickerControllerSourceTypeSavedPhotosAlbum = 2` is deliberately | |
| 212 | /// absent — no action posts it. | |
| 213 | const Source = enum(c_long) { | |
| 214 | photo_library = 0, | |
| 215 | camera = 1, | |
| 216 | ||
| 217 | fn actionName(self: Source) []const u8 { | |
| 218 | return switch (self) { | |
| 219 | .camera => A.open_camera, | |
| 220 | .photo_library => A.pick_image, | |
| 221 | }; | |
| 222 | } | |
| 223 | }; | |
| 224 | ||
| 225 | /// Which picker an action selects, split out from `handleMessage` so the | |
| 226 | /// table-versus-dispatch agreement is assertable on a host that has no UIKit. | |
| 227 | fn routeFor(action: []const u8) ?Source { | |
| 228 | if (std.mem.eql(u8, action, A.open_camera)) return .camera; | |
| 229 | if (std.mem.eql(u8, action, A.pick_image)) return .photo_library; | |
| 230 | return null; | |
| 231 | } | |
| 232 | ||
| 233 | /// `image.jpegData(compressionQuality: 0.8)` — Swift's spelling, `:2646`. | |
| 234 | /// Always JPEG, always this quality, for both actions. | |
| 235 | const jpeg_compression_quality: f64 = 0.8; | |
| 236 | ||
| 237 | pub const ImagePickerBridge = struct { | |
| 238 | allocator: std.mem.Allocator, | |
| 239 | ||
| 240 | const Self = @This(); | |
| 241 | ||
| 242 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 243 | return .{ .allocator = allocator }; | |
| 244 | } | |
| 245 | ||
| 246 | pub fn deinit(_: *Self) void {} | |
| 247 | ||
| 248 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 249 | const source = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; | |
| 250 | return self.present(source, data); | |
| 251 | } | |
| 252 | ||
| 253 | /// Configure a `UIImagePickerController` for `source`, present it, and let | |
| 254 | /// the delegate answer when the user is done. | |
| 255 | /// | |
| 256 | /// `data` is accepted and ignored — see the module comment; neither JS | |
| 257 | /// surface sends a field and the Swift dispatcher reads none. | |
| 258 | /// | |
| 259 | /// Every fallible step runs *before* `ios_async.acquire`: the config gate, | |
| 260 | /// the class, the camera-availability question, all five delegate | |
| 261 | /// selectors, both `dlsym`s, the delegate class and instance, the | |
| 262 | /// presenter, the picker itself and every selector used to present it. | |
| 263 | /// `bridge_mobile_location.zig` states the rule this follows: no error path | |
| 264 | /// may exist between leasing a slot and handing the request to the | |
| 265 | /// framework, because a missed `abandon` permanently narrows the pool. The | |
| 266 | /// one exception below — `publish` answering `.busy` — releases the lease | |
| 267 | /// by hand on the way out, and is the only path that has to. | |
| 268 | fn present(self: *Self, source: Source, data: []const u8) !void { | |
| 269 | _ = self; | |
| 270 | _ = data; | |
| 271 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 272 | ||
| 273 | const action = source.actionName(); | |
| 274 | ||
| 275 | // The config gate first. It is the only refusal that touches no UIKit | |
| 276 | // at all, and on a host build it is what keeps everything below from | |
| 277 | // running against a runtime that has no picker in it. | |
| 278 | try requireConfigured(source); | |
| 279 | ||
| 280 | // A cheap refusal before any of the expensive work: a modal is on | |
| 281 | // screen, so nothing built here could be presented anyway. `publish` | |
| 282 | // below is the authoritative one — it decides under the lock, so a | |
| 283 | // request that slipped past this read still cannot displace the first. | |
| 284 | if (busyWith()) |holder| return busyRefusal(action, holder); | |
| 285 | ||
| 286 | const PickerClass = objc.objc_getClass("UIImagePickerController") orelse { | |
| 287 | std.log.warn( | |
| 288 | "{s} refused: this process has no UIImagePickerController class, " ++ | |
| 289 | "so UIKit is not loaded here", | |
| 290 | .{action}, | |
| 291 | ); | |
| 292 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 293 | }; | |
| 294 | ||
| 295 | // Swift's `guard UIImagePickerController.isSourceTypeAvailable(.camera)`. | |
| 296 | // Asked, never assumed: on a simulator the answer is NO, and presenting | |
| 297 | // anyway shows nothing and never calls the delegate back. | |
| 298 | if (source == .camera) { | |
| 299 | const sel_available = try selector("isSourceTypeAvailable:"); | |
| 300 | const AvailableFn = *const fn (Id, Id, c_long) callconv(.c) bool; | |
| 301 | const available: AvailableFn = @ptrCast(&objc.objc_msgSend); | |
| 302 | if (!available(PickerClass, sel_available, @backingInt(Source.camera))) { | |
| 303 | std.log.warn( | |
| 304 | "openCamera refused: no camera source is available on this device " ++ | |
| 305 | "(every simulator answers NO)", | |
| 306 | .{}, | |
| 307 | ); | |
| 308 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | // Everything the delegate will need to shape a reply, resolved while an | |
| 313 | // error is still deliverable to the caller. | |
| 314 | const work = try Work.resolve(); | |
| 315 | ||
| 316 | const delegate = try ensureDelegate(); | |
| 317 | const presenter = try bridge_mobile_system.uikit.topmostViewController(); | |
| 318 | ||
| 319 | const sel_set_source = try selector("setSourceType:"); | |
| 320 | const sel_set_delegate = try selector("setDelegate:"); | |
| 321 | const sel_present = try selector("presentViewController:animated:completion:"); | |
| 322 | ||
| 323 | const picker = (try objc.allocInit(PickerClass)) orelse return error.NativeCallFailed; | |
| 324 | // +1 from alloc/init. The presenting controller retains the picker for | |
| 325 | // the life of the presentation, so this reference is ours to drop — | |
| 326 | // and on every error path below it is the only one there is. Same | |
| 327 | // shape as `bridge_mobile_system.share`. | |
| 328 | defer objc.release(picker); | |
| 329 | ||
| 330 | const SetSourceFn = *const fn (Id, Id, c_long) callconv(.c) void; | |
| 331 | const set_source: SetSourceFn = @ptrCast(&objc.objc_msgSend); | |
| 332 | set_source(picker, sel_set_source, @backingInt(source)); | |
| 333 | ||
| 334 | // `mediaTypes` and `allowsEditing` are deliberately left alone: Swift | |
| 335 | // sets neither, so the defaults (`["public.image"]` and `NO`) are the | |
| 336 | // contract, and `.originalImage` is the right info key precisely | |
| 337 | // because editing is off. | |
| 338 | objc.msgSendVoid1(picker, sel_set_delegate, delegate); | |
| 339 | ||
| 340 | // If UIKit adapts this presentation to a popover — which it can on a | |
| 341 | // regular size class, i.e. any iPad and iPhone in some multitasking | |
| 342 | // configurations — it raises `NSGenericException` when neither | |
| 343 | // `sourceView` nor `barButtonItem` is set, and an Objective-C exception | |
| 344 | // is an uncatchable crash from Zig. `anchorPopover` reads | |
| 345 | // `popoverPresentationController` and returns immediately when it is | |
| 346 | // nil, which Apple documents it to be unless the modal presentation | |
| 347 | // style is `.popover`. So this either anchors a popover correctly or | |
| 348 | // does nothing at all; which of the two happens is UIKit's decision and | |
| 349 | // is not predicted here. | |
| 350 | try bridge_mobile_system.uikit.anchorPopover(picker, presenter); | |
| 351 | ||
| 352 | var dismiss_observed = attachDismissObserver(picker, delegate, sel_set_delegate); | |
| 353 | ||
| 354 | const PresentFn = *const fn (Id, Id, Id, bool, Id) callconv(.c) void; | |
| 355 | const present_fn: PresentFn = @ptrCast(&objc.objc_msgSend); | |
| 356 | ||
| 357 | const ticket = ios_async.acquire(action) orelse return poolFull(action); | |
| 358 | ||
| 359 | // Published before the framework call, never after: presentation is | |
| 360 | // asynchronous but a delegate callback that reached an empty slot would | |
| 361 | // have no ticket to answer with. | |
| 362 | switch (publish(.{ .ticket = ticket, .source = source, .work = work })) { | |
| 363 | .published => {}, | |
| 364 | .busy => |holder| { | |
| 365 | // Lost a race with another dispatch. The lease is released by | |
| 366 | // hand here because this is the one error path that exists | |
| 367 | // after `acquire`; leaving it would narrow the pool forever. | |
| 368 | ios_async.abandon(ticket); | |
| 369 | return busyRefusal(action, holder); | |
| 370 | }, | |
| 371 | } | |
| 372 | ||
| 373 | present_fn(presenter, sel_present, picker, true, null); | |
| 374 | ||
| 375 | // A presentation controller may only exist once the presentation has | |
| 376 | // begun. Retried here rather than claimed above, because the interactive | |
| 377 | // -dismissal callback is the only thing that can free `pending` when a | |
| 378 | // sheet is swiped away and `imagePickerControllerDidCancel:` does not | |
| 379 | // fire. | |
| 380 | if (!dismiss_observed) dismiss_observed = attachDismissObserver(picker, delegate, sel_set_delegate); | |
| 381 | if (!dismiss_observed) { | |
| 382 | std.log.info( | |
| 383 | "{s}: no presentation controller to observe; a swipe-down dismissal " ++ | |
| 384 | "may leave this call unanswered", | |
| 385 | .{action}, | |
| 386 | ); | |
| 387 | } | |
| 388 | } | |
| 389 | }; | |
| 390 | ||
| 391 | /// The answer for a full slot pool, copied from `bridge_mobile_location`: | |
| 392 | /// `BridgeError` has no "Busy", `INVALID_PARAMETER` is the migration notes' | |
| 393 | /// designated stand-in, and the point is that the caller gets an explicit | |
| 394 | /// rejection instead of a promise that never settles. | |
| 395 | fn poolFull(action: []const u8) bridge_error.BridgeError { | |
| 396 | std.log.warn( | |
| 397 | "{s} refused: all {d} async slots in flight", | |
| 398 | .{ action, ios_async.max_in_flight }, | |
| 399 | ); | |
| 400 | return bridge_error.BridgeError.InvalidParameter; | |
| 401 | } | |
| 402 | ||
| 403 | /// The answer for a second request while a picker is already on screen. | |
| 404 | /// | |
| 405 | /// The *second* caller is refused and the first is left alone, because the | |
| 406 | /// first picker is still up and will answer. Same `INVALID_PARAMETER` | |
| 407 | /// stand-in, for the same reason: `BridgeError` cannot say "busy", and silence | |
| 408 | /// is not an option. | |
| 409 | fn busyRefusal(action: []const u8, holder: Source) bridge_error.BridgeError { | |
| 410 | std.log.warn( | |
| 411 | "{s} refused: {s} already has a picker presented; refusing the second call rather " ++ | |
| 412 | "than replacing the first, whose promise would then never settle", | |
| 413 | .{ action, holder.actionName() }, | |
| 414 | ); | |
| 415 | return bridge_error.BridgeError.InvalidParameter; | |
| 416 | } | |
| 417 | ||
| 418 | // ============================================================================= | |
| 419 | // Reply shaping. Pure — no Objective-C — so the exact bytes the page receives | |
| 420 | // are pinned by host tests on every platform. | |
| 421 | // ============================================================================= | |
| 422 | ||
| 423 | /// One picked image, read into plain values. | |
| 424 | /// | |
| 425 | /// `base64` is the raw encoding, owned by whoever built it. `width`/`height` | |
| 426 | /// are `-[UIImage size]` in points, which is what Swift puts on the wire. | |
| 427 | const Photo = struct { | |
| 428 | base64: []const u8, | |
| 429 | width: f64, | |
| 430 | height: f64, | |
| 431 | }; | |
| 432 | ||
| 433 | /// The five-key object Swift's `didFinishPickingMediaWithInfo` resolves. | |
| 434 | /// | |
| 435 | /// Key order is fixed at base64, uri, width, height, mimeType. Swift's is a | |
| 436 | /// `Dictionary` and therefore arbitrary; a caller cannot depend on it, but a | |
| 437 | /// test can only pin bytes that are deterministic, so one order is chosen and | |
| 438 | /// held. | |
| 439 | /// | |
| 440 | /// The encoded payload is appended **twice** — once raw, once behind the | |
| 441 | /// `data:` prefix — because that is what `PhotoResult` is written for. It is | |
| 442 | /// the single largest cost in this path and it is not this module's to change. | |
| 443 | fn shapePhoto(allocator: std.mem.Allocator, photo: Photo) ![]u8 { | |
| 444 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 445 | errdefer out.deinit(allocator); | |
| 446 | ||
| 447 | // Not escaped, and that is checked rather than asserted: the standard | |
| 448 | // base64 alphabet plus its pad character contains no byte JSON must | |
| 449 | // escape. See "the encoded payload needs no JSON escaping" below, which | |
| 450 | // fails if the encoder is ever swapped for one whose alphabet does. | |
| 451 | try out.appendSlice(allocator, "{\"base64\":\""); | |
| 452 | try out.appendSlice(allocator, photo.base64); | |
| 453 | try out.appendSlice(allocator, "\",\"uri\":\""); | |
| 454 | try out.appendSlice(allocator, data_uri_prefix); | |
| 455 | try out.appendSlice(allocator, photo.base64); | |
| 456 | try out.appendSlice(allocator, "\",\"width\":"); | |
| 457 | try appendNumber(allocator, &out, photo.width); | |
| 458 | try out.appendSlice(allocator, ",\"height\":"); | |
| 459 | try appendNumber(allocator, &out, photo.height); | |
| 460 | try out.appendSlice(allocator, ",\"mimeType\":\""); | |
| 461 | try out.appendSlice(allocator, mime_type); | |
| 462 | try out.appendSlice(allocator, "\"}"); | |
| 463 | ||
| 464 | return out.toOwnedSlice(allocator); | |
| 465 | } | |
| 466 | ||
| 467 | /// Swift's `"data:image/jpeg;base64," + base64`. | |
| 468 | const data_uri_prefix = "data:image/jpeg;base64,"; | |
| 469 | ||
| 470 | /// The literal Swift sends for both actions, always — the image is re-encoded | |
| 471 | /// to JPEG, so a PNG or HEIC chosen from the library still arrives as this. | |
| 472 | const mime_type = "image/jpeg"; | |
| 473 | ||
| 474 | /// One `f64` as a JSON number. | |
| 475 | /// | |
| 476 | /// `{d}` renders decimal notation, never scientific, and prints an integral | |
| 477 | /// value with no fraction — `3024`, matching what `JSONSerialization` writes | |
| 478 | /// for the same `CGFloat`. A finite `f64` can need up to | |
| 479 | /// `std.fmt.float.bufferSize(.decimal, f64)` (347) bytes, so the buffer is | |
| 480 | /// sized from the formatter rather than guessed. | |
| 481 | /// | |
| 482 | /// Non-finite is refused rather than printed: `inf` and `nan` are not JSON, | |
| 483 | /// and `JSONSerialization` refuses them too. A refusal reaches the page as a | |
| 484 | /// rejection; printing them would produce a syntax error inside the reply | |
| 485 | /// script with nothing to point at. | |
| 486 | fn appendNumber( | |
| 487 | allocator: std.mem.Allocator, | |
| 488 | out: *std.ArrayListUnmanaged(u8), | |
| 489 | value: f64, | |
| 490 | ) !void { | |
| 491 | if (!std.math.isFinite(value)) return bridge_error.BridgeError.InvalidParameter; | |
| 492 | var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined; | |
| 493 | try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value})); | |
| 494 | } | |
| 495 | ||
| 496 | /// What a delegate callback decided to send. | |
| 497 | /// | |
| 498 | /// Deciding and sending are separate so the decision is checkable without a | |
| 499 | /// picker: getting `.reject` and `.photo` the wrong way round is the difference | |
| 500 | /// between an app's `catch` running and its `then` running on an image that | |
| 501 | /// does not exist. | |
| 502 | const Answer = union(enum) { | |
| 503 | /// The five-key object, ready to hand to `ios_async.deliverJson`. | |
| 504 | photo: []const u8, | |
| 505 | /// A rejection. Every failure *and* every cancel takes this arm. | |
| 506 | reject, | |
| 507 | }; | |
| 508 | ||
| 509 | /// The two ways this module answers a captured call, and the only two. | |
| 510 | /// | |
| 511 | /// Neither replies directly — `evaluateJavaScript` is main-thread-only and | |
| 512 | /// these run on a framework callback — so both go through `ios_async`, which | |
| 513 | /// hops to the main queue under the request id captured back at dispatch. | |
| 514 | fn settle(call: Pending, answer: Answer) void { | |
| 515 | switch (answer) { | |
| 516 | .photo => |json| ios_async.deliverJson(call.ticket, json), | |
| 517 | // A Cancel is not a native failure — the call worked and the answer | |
| 518 | // is "no" — so it carries the code that says so. | |
| 519 | .reject => ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.Cancelled), | |
| 520 | } | |
| 521 | } | |
| 522 | ||
| 523 | /// Swift's `if let image = …, let imageData = … { resolve } else { reject }`. | |
| 524 | /// A reply that could not be built is a rejection, never an object with an | |
| 525 | /// empty payload and zeroed dimensions — `normalizePhoto` would hand the app | |
| 526 | /// exactly that and its `then` would run. | |
| 527 | fn finishAnswer(json: ?[]const u8) Answer { | |
| 528 | return if (json) |bytes| .{ .photo = bytes } else .reject; | |
| 529 | } | |
| 530 | ||
| 531 | /// A cancel is a **rejection**. Swift is | |
| 532 | /// `rejectCallback(pendingCallbackId, error: "Cancelled")`, and | |
| 533 | /// `craft-bridge.js` resolves with `payload || {}`, so any resolved shape — | |
| 534 | /// `null`, `false`, `{}` — would run the app's `then` branch for a picture the | |
| 535 | /// user declined to take. | |
| 536 | const cancel_answer: Answer = .reject; | |
| 537 | ||
| 538 | // ============================================================================= | |
| 539 | // The config gate. See the module comment for what each key does and does not | |
| 540 | // stand for. | |
| 541 | // ============================================================================= | |
| 542 | ||
| 543 | /// Written by `packages/ios/src/index.ts:185` for | |
| 544 | /// `enableCamera || enableVideoRecording || enableQRScanner || enableAR`. | |
| 545 | /// Required before a `.camera` picker may be presented at all. | |
| 546 | const key_camera_usage = "NSCameraUsageDescription"; | |
| 547 | ||
| 548 | /// Written by `packages/ios/src/index.ts:186` for | |
| 549 | /// `enableCamera || enableVideoRecording`. Used here as the closest available | |
| 550 | /// proxy for `config.enableCamera` — **not** as a permission gate; a | |
| 551 | /// `.photoLibrary` picker needs no such key on iOS 11+. | |
| 552 | const key_photo_library_usage = "NSPhotoLibraryUsageDescription"; | |
| 553 | ||
| 554 | fn requireConfigured(source: Source) !void { | |
| 555 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 556 | ||
| 557 | // Checked first, and only for the camera: this one is a crash guard, not a | |
| 558 | // configuration question. Presenting a `.camera` picker in a process | |
| 559 | // without the key is a TCC termination — there is no error to map and | |
| 560 | // nothing left to tell the page. | |
| 561 | if (source == .camera and !try infoPlistHas(key_camera_usage)) { | |
| 562 | std.log.warn( | |
| 563 | "openCamera refused: Info.plist has no {s}, and presenting a camera picker " ++ | |
| 564 | "without it terminates the process rather than failing", | |
| 565 | .{key_camera_usage}, | |
| 566 | ); | |
| 567 | return bridge_error.BridgeError.PermissionDenied; | |
| 568 | } | |
| 569 | ||
| 570 | if (!try infoPlistHas(key_photo_library_usage)) { | |
| 571 | std.log.warn( | |
| 572 | "{s} refused: Info.plist has no {s}, the closest available proxy for " ++ | |
| 573 | "config.enableCamera, so this app was not built with the camera enabled", | |
| 574 | .{ source.actionName(), key_photo_library_usage }, | |
| 575 | ); | |
| 576 | return bridge_error.BridgeError.PermissionDenied; | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | /// Whether the main bundle's Info.plist carries `key`. | |
| 581 | /// | |
| 582 | /// Errors rather than answering `false` when the runtime itself will not | |
| 583 | /// cooperate: "there is no NSBundle class" and "this app did not enable the | |
| 584 | /// camera" are different facts, and collapsing them would blame the app's | |
| 585 | /// configuration for a broken process. Every runtime result is guarded. | |
| 586 | fn infoPlistHas(comptime key: [*:0]const u8) !bool { | |
| 587 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 588 | ||
| 589 | const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; | |
| 590 | const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; | |
| 591 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle; | |
| 592 | ||
| 593 | const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound; | |
| 594 | const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse | |
| 595 | return error.SelectorNotFound; | |
| 596 | const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed; | |
| 597 | ||
| 598 | const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse | |
| 599 | return error.SelectorNotFound; | |
| 600 | return objc.msgSendId1(bundle, sel_lookup, ns_key) != null; | |
| 601 | } | |
| 602 | ||
| 603 | // ============================================================================= | |
| 604 | // Everything the delegate needs, resolved while an error is still deliverable. | |
| 605 | // ============================================================================= | |
| 606 | ||
| 607 | fn selector(name: [*:0]const u8) !Id { | |
| 608 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 609 | return objc.sel_registerName(name) orelse error.SelectorNotFound; | |
| 610 | } | |
| 611 | ||
| 612 | /// The five selectors a delegate callback sends. | |
| 613 | /// | |
| 614 | /// Resolved at dispatch for the reason `bridge_mobile_location.Sels` states: a | |
| 615 | /// `sel_registerName` failure inside a callback could only be logged, or worse | |
| 616 | /// turned into a rejection for a picture the user actually took. | |
| 617 | const Sels = struct { | |
| 618 | object_for_key: Id, | |
| 619 | size: Id, | |
| 620 | bytes: Id, | |
| 621 | length: Id, | |
| 622 | /// `-[UIViewController dismissViewControllerAnimated:completion:]`, sent to | |
| 623 | /// the picker; UIKit forwards it to the presenter. Both Swift callbacks | |
| 624 | /// call it, and missing either one leaves the app behind a modal forever. | |
| 625 | dismiss: Id, | |
| 626 | ||
| 627 | fn resolve() !Sels { | |
| 628 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 629 | return .{ | |
| 630 | .object_for_key = try selector("objectForKey:"), | |
| 631 | .size = try selector("size"), | |
| 632 | .bytes = try selector("bytes"), | |
| 633 | .length = try selector("length"), | |
| 634 | .dismiss = try selector("dismissViewControllerAnimated:completion:"), | |
| 635 | }; | |
| 636 | } | |
| 637 | }; | |
| 638 | ||
| 639 | /// `NSData *UIImageJPEGRepresentation(UIImage *, CGFloat)`. | |
| 640 | /// | |
| 641 | /// `CGFloat` is `double` on every 64-bit Apple platform. This is a plain C | |
| 642 | /// function with no Objective-C equivalent — `-jpegData(compressionQuality:)` | |
| 643 | /// is Swift-only sugar over it. | |
| 644 | const JpegFn = *const fn (Id, f64) callconv(.c) Id; | |
| 645 | ||
| 646 | /// Everything a delegate callback needs, captured for the call it will answer. | |
| 647 | const Work = struct { | |
| 648 | sels: Sels, | |
| 649 | /// The value of `UIImagePickerControllerOriginalImage`, the info key Swift | |
| 650 | /// reads. Resolved before presenting, so a UIKit that does not carry it is | |
| 651 | /// a synchronous refusal rather than a picker nobody can read the result of. | |
| 652 | original_image_key: Id, | |
| 653 | jpeg: JpegFn, | |
| 654 | ||
| 655 | fn resolve() !Work { | |
| 656 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 657 | ||
| 658 | // `dlsym` rather than `extern "c"`: the host test binaries link | |
| 659 | // Cocoa/WebKit/CoreGraphics and not UIKit, and `refAllDecls` forces | |
| 660 | // analysis of every declaration, so an `extern "c" fn | |
| 661 | // UIImageJPEGRepresentation` would be an undefined symbol at host link | |
| 662 | // time. The same route `bridge_mobile_location.bestAccuracy` takes for | |
| 663 | // `kCLLocationAccuracyBest`. | |
| 664 | const jpeg_symbol = dlsym(RTLD_DEFAULT, "UIImageJPEGRepresentation") orelse { | |
| 665 | std.log.warn( | |
| 666 | "image picker refused: UIImageJPEGRepresentation is not in this process, " ++ | |
| 667 | "so a picked image could not be encoded", | |
| 668 | .{}, | |
| 669 | ); | |
| 670 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 671 | }; | |
| 672 | ||
| 673 | // `UIImagePickerControllerOriginalImage` is an `extern NSString * const` | |
| 674 | // in UIKit. Read through the symbol rather than rebuilt from its own | |
| 675 | // spelling: the constant's *value* happening to equal its name is | |
| 676 | // undocumented, and a null answer here is the honest "UIKit is not | |
| 677 | // here" rather than a plausible-looking string that matches nothing. | |
| 678 | const key_symbol = dlsym(RTLD_DEFAULT, "UIImagePickerControllerOriginalImage") orelse { | |
| 679 | std.log.warn( | |
| 680 | "image picker refused: UIImagePickerControllerOriginalImage is not in this " ++ | |
| 681 | "process, so a pick could not be read", | |
| 682 | .{}, | |
| 683 | ); | |
| 684 | return bridge_error.BridgeError.PlatformNotSupported; | |
| 685 | }; | |
| 686 | const key_cell: *const Id = @ptrCast(@alignCast(key_symbol)); | |
| 687 | const original_image_key = key_cell.* orelse return error.NativeCallFailed; | |
| 688 | ||
| 689 | return .{ | |
| 690 | .sels = try Sels.resolve(), | |
| 691 | .original_image_key = original_image_key, | |
| 692 | // `@alignCast` because `dlsym` answers with a `*anyopaque`, whose | |
| 693 | // alignment is 1; a function pointer's is 4. The assertion is sound — | |
| 694 | // this address is a function entry point in a loaded image. | |
| 695 | .jpeg = @ptrCast(@alignCast(jpeg_symbol)), | |
| 696 | }; | |
| 697 | } | |
| 698 | }; | |
| 699 | ||
| 700 | extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; | |
| 701 | ||
| 702 | /// dyld's "search every image" pseudo-handle, `(void *)-2` on Darwin. | |
| 703 | const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); | |
| 704 | ||
| 705 | // ============================================================================= | |
| 706 | // The delegate class, built at runtime. | |
| 707 | // ============================================================================= | |
| 708 | ||
| 709 | /// Deliberately namespaced, on the `CraftIOSLocationDelegate` precedent: a | |
| 710 | /// generic name risks colliding with a desktop class in the same host test | |
| 711 | /// binary, and both sides look their class up with `objc_getClass` first — so | |
| 712 | /// whichever registered second would silently adopt the other's IMPs. | |
| 713 | const delegate_class_name = "CraftIOSImagePickerDelegate"; | |
| 714 | ||
| 715 | /// The delegate instance, held for the life of the process. | |
| 716 | /// | |
| 717 | /// `UIImagePickerController` holds its `delegate` **weakly**, and | |
| 718 | /// `ios_delegate.instantiate` returns +1 while retaining nothing. This var is | |
| 719 | /// the strong reference; without it the first Cancel is a use-after-free. | |
| 720 | var delegate_instance: Id = null; | |
| 721 | ||
| 722 | /// The method table, so the encodings are assertable on a host. | |
| 723 | /// | |
| 724 | /// A wrong encoding string still registers and still dispatches, then reads | |
| 725 | /// arguments out of the wrong registers with no crash and no compile error — | |
| 726 | /// which is why `ios_delegate.enc` names the shapes rather than spelling them. | |
| 727 | /// | |
| 728 | /// A function rather than a `const`: `@ptrCast` from a function pointer to | |
| 729 | /// `*const anyopaque` is a runtime cast, and a container-level initialiser | |
| 730 | /// would ask the compiler to perform it at comptime. | |
| 731 | /// | |
| 732 | /// The three IMPs are plain `fn … callconv(.c)` rather than `export fn`. What | |
| 733 | /// `class_addMethod` needs is the C calling convention and the `(self, _cmd, …)` | |
| 734 | /// parameter order, both of which a plain function has; an exported symbol | |
| 735 | /// would additionally be visible to the linker, which is how the desktop and | |
| 736 | /// iOS location delegates collided once already. | |
| 737 | fn delegateMethods() [3]ios_delegate.Method { | |
| 738 | return .{ | |
| 739 | // - (void)imagePickerController:(UIImagePickerController *)picker | |
| 740 | // didFinishPickingMediaWithInfo:(NSDictionary *)info | |
| 741 | .{ | |
| 742 | .selector = "imagePickerController:didFinishPickingMediaWithInfo:", | |
| 743 | .imp = @ptrCast(&craftIOSImagePickerDidFinish), | |
| 744 | .types = ios_delegate.enc.void_two_objects, | |
| 745 | }, | |
| 746 | // - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker | |
| 747 | .{ | |
| 748 | .selector = "imagePickerControllerDidCancel:", | |
| 749 | .imp = @ptrCast(&craftIOSImagePickerDidCancel), | |
| 750 | .types = ios_delegate.enc.void_one_object, | |
| 751 | }, | |
| 752 | // - (void)presentationControllerDidDismiss:(UIPresentationController *)pc | |
| 753 | // The swipe-down belt. iOS 13+; on anything older it is simply never sent. | |
| 754 | .{ | |
| 755 | .selector = "presentationControllerDidDismiss:", | |
| 756 | .imp = @ptrCast(&craftIOSImagePickerDidDismiss), | |
| 757 | .types = ios_delegate.enc.void_one_object, | |
| 758 | }, | |
| 759 | }; | |
| 760 | } | |
| 761 | ||
| 762 | /// Register the delegate class once, and keep one instance alive. | |
| 763 | /// | |
| 764 | /// `UIImagePickerControllerDelegate`, `UINavigationControllerDelegate` and | |
| 765 | /// `UIAdaptivePresentationControllerDelegate` are deliberately not declared: | |
| 766 | /// UIKit dispatches through `respondsToSelector:`, so conformance is not | |
| 767 | /// required, and Swift implements none of the navigation-delegate methods it | |
| 768 | /// declares at `:376` either. | |
| 769 | fn ensureDelegate() !Id { | |
| 770 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 771 | if (delegate_instance) |existing| return existing; | |
| 772 | ||
| 773 | const methods = delegateMethods(); | |
| 774 | const cls = try ios_delegate.defineClass(delegate_class_name, "NSObject", &methods); | |
| 775 | const instance = (try ios_delegate.instantiate(cls)) orelse return error.NativeCallFailed; | |
| 776 | delegate_instance = instance; | |
| 777 | return instance; | |
| 778 | } | |
| 779 | ||
| 780 | /// Make the picker's presentation controller report an interactive dismissal | |
| 781 | /// to us. Returns whether it was attached. | |
| 782 | /// | |
| 783 | /// A `.photoLibrary` picker has presented as a sheet since iOS 13, and a | |
| 784 | /// swipe-down is widely reported not to call | |
| 785 | /// `imagePickerControllerDidCancel:`. That report is not verified anywhere in | |
| 786 | /// this repo, so this is a belt and not a fix: it costs two messages, and if | |
| 787 | /// the report is right it is the only thing that frees `pending` after a | |
| 788 | /// swipe. This module refuses later calls while one is pending, so a stranded | |
| 789 | /// ticket is worse here than in Swift — it would disable the picker for the | |
| 790 | /// life of the process. | |
| 791 | /// | |
| 792 | /// Idempotent against the real cancel: whichever callback runs first takes | |
| 793 | /// `pending` under the lock, so the second finds nothing, and `ios_async`'s | |
| 794 | /// generation counter would make a second delivery a no-op anyway. | |
| 795 | fn attachDismissObserver(picker: Id, delegate: Id, sel_set_delegate: Id) bool { | |
| 796 | if (!is_darwin) return false; | |
| 797 | ||
| 798 | const sel_presentation = objc.sel_registerName("presentationController") orelse return false; | |
| 799 | const presentation = objc.msgSendId(picker, sel_presentation) orelse return false; | |
| 800 | objc.msgSendVoid1(presentation, sel_set_delegate, delegate); | |
| 801 | return true; | |
| 802 | } | |
| 803 | ||
| 804 | // ============================================================================= | |
| 805 | // One pending call for the whole module, because one modal serves one caller. | |
| 806 | // ============================================================================= | |
| 807 | ||
| 808 | const Pending = struct { | |
| 809 | ticket: ios_async.Ticket, | |
| 810 | source: Source, | |
| 811 | work: Work, | |
| 812 | }; | |
| 813 | ||
| 814 | var pending: ?Pending = null; | |
| 815 | ||
| 816 | /// Guards `pending`. Held even though UIKit delivers these callbacks on the | |
| 817 | /// main thread, because "it should always be the main thread" is not a guard | |
| 818 | /// and the dispatch side is not provably main-thread for every entry point. | |
| 819 | var state_mutex: compat_mutex.Mutex = .{}; | |
| 820 | ||
| 821 | /// `.busy` carries who is holding the picker, so the refusal can name it. | |
| 822 | const PublishResult = union(enum) { published, busy: Source }; | |
| 823 | ||
| 824 | /// Record the call the delegate will answer, refusing rather than replacing. | |
| 825 | /// | |
| 826 | /// The opposite of `bridge_mobile_location.publishPendingFix`, deliberately: | |
| 827 | /// there the framework cancels the earlier request, so the displaced caller | |
| 828 | /// can never be answered and must be rejected. Here the first picker is still | |
| 829 | /// on screen and will answer, so replacing it would strand a promise that was | |
| 830 | /// about to settle. | |
| 831 | fn publish(call: Pending) PublishResult { | |
| 832 | state_mutex.lock(); | |
| 833 | defer state_mutex.unlock(); | |
| 834 | if (pending) |existing| return .{ .busy = existing.source }; | |
| 835 | pending = call; | |
| 836 | return .published; | |
| 837 | } | |
| 838 | ||
| 839 | /// Read and clear. Clearing is what makes a second delegate call — a late | |
| 840 | /// fire, or the dismissal belt after a real cancel — a no-op rather than a | |
| 841 | /// second reply, and it is what frees the picker for the next caller. | |
| 842 | fn takePending() ?Pending { | |
| 843 | state_mutex.lock(); | |
| 844 | defer state_mutex.unlock(); | |
| 845 | const call = pending; | |
| 846 | pending = null; | |
| 847 | return call; | |
| 848 | } | |
| 849 | ||
| 850 | /// Which action is holding the picker, or null when it is free. | |
| 851 | fn busyWith() ?Source { | |
| 852 | state_mutex.lock(); | |
| 853 | defer state_mutex.unlock(); | |
| 854 | return if (pending) |call| call.source else null; | |
| 855 | } | |
| 856 | ||
| 857 | // ============================================================================= | |
| 858 | // The delegate methods. | |
| 859 | // | |
| 860 | // None of them replies directly: `evaluateJavaScript` is main-thread-only and | |
| 861 | // these are framework callbacks, so the finished JSON goes to | |
| 862 | // `ios_async.deliverJson` (or `deliverError`), which hops to the main queue and | |
| 863 | // answers under the request id captured back at dispatch. | |
| 864 | // ============================================================================= | |
| 865 | ||
| 866 | /// `-imagePickerController:didFinishPickingMediaWithInfo:` | |
| 867 | fn craftIOSImagePickerDidFinish(_: Id, _: Id, picker: Id, info: Id) callconv(.c) void { | |
| 868 | if (!is_darwin) return; | |
| 869 | ||
| 870 | const call = takePending(); | |
| 871 | ||
| 872 | // Swift's first line, and unconditional for a reason: a shaping failure | |
| 873 | // below must still take the modal off the screen. A rejected promise is | |
| 874 | // recoverable; an app stuck behind a picker it cannot close is not. | |
| 875 | dismissPresented(picker, if (call) |c| c.work.sels.dismiss else null); | |
| 876 | ||
| 877 | const answering = call orelse { | |
| 878 | std.log.info( | |
| 879 | "image picker: a pick arrived with no call waiting; ignored rather than " ++ | |
| 880 | "answered twice", | |
| 881 | .{}, | |
| 882 | ); | |
| 883 | return; | |
| 884 | }; | |
| 885 | ||
| 886 | const allocator = std.heap.c_allocator; | |
| 887 | ||
| 888 | // Swift's `"Failed to process image"` branch is every failure here: a nil | |
| 889 | // image, a JPEG encode that returned nothing, a reply that would not shape. | |
| 890 | const json: ?[]u8 = buildPhotoReply(info, answering.work, allocator) catch |err| shaped: { | |
| 891 | std.log.err( | |
| 892 | "{s}: could not build the reply for the picked image ({}); rejecting rather " ++ | |
| 893 | "than replying with an image that was not encoded", | |
| 894 | .{ answering.source.actionName(), err }, | |
| 895 | ); | |
| 896 | break :shaped null; | |
| 897 | }; | |
| 898 | defer if (json) |owned| allocator.free(owned); | |
| 899 | ||
| 900 | settle(answering, finishAnswer(json)); | |
| 901 | } | |
| 902 | ||
| 903 | /// Read the pick and shape it, or fail. Split from the callback so the | |
| 904 | /// callback has exactly one reply site. | |
| 905 | fn buildPhotoReply(info: Id, work: Work, allocator: std.mem.Allocator) ![]u8 { | |
| 906 | const photo = try readPhoto(info, work, allocator); | |
| 907 | defer allocator.free(photo.base64); | |
| 908 | return shapePhoto(allocator, photo); | |
| 909 | } | |
| 910 | ||
| 911 | /// `-imagePickerControllerDidCancel:` | |
| 912 | fn craftIOSImagePickerDidCancel(_: Id, _: Id, picker: Id) callconv(.c) void { | |
| 913 | if (!is_darwin) return; | |
| 914 | ||
| 915 | const call = takePending(); | |
| 916 | dismissPresented(picker, if (call) |c| c.work.sels.dismiss else null); | |
| 917 | ||
| 918 | const answering = call orelse { | |
| 919 | std.log.info("image picker: a cancel arrived with no call waiting; ignored", .{}); | |
| 920 | return; | |
| 921 | }; | |
| 922 | ||
| 923 | // A rejection, per `cancel_answer`. The code it carries is | |
| 924 | // `NATIVE_CALL_FAILED` and not Swift's `"Cancelled"`, because | |
| 925 | // `ios_async.deliverError` has no way to name an error — see the module | |
| 926 | // comment, which is the only place that says so. | |
| 927 | settle(answering, cancel_answer); | |
| 928 | } | |
| 929 | ||
| 930 | /// `-presentationControllerDidDismiss:` — the swipe-down belt. | |
| 931 | /// | |
| 932 | /// Nothing is dismissed here: UIKit has already taken the sheet off the | |
| 933 | /// screen, which is precisely what this callback reports. Sending | |
| 934 | /// `dismissViewControllerAnimated:` now could take down whatever the presenter | |
| 935 | /// shows next instead. | |
| 936 | fn craftIOSImagePickerDidDismiss(_: Id, _: Id, _: Id) callconv(.c) void { | |
| 937 | if (!is_darwin) return; | |
| 938 | ||
| 939 | // Empty is the ordinary case: a real cancel or a real pick already took it. | |
| 940 | const answering = takePending() orelse return; | |
| 941 | ||
| 942 | std.log.info( | |
| 943 | "{s}: the picker was dismissed interactively without a delegate callback; " ++ | |
| 944 | "rejecting rather than leaving the call unanswered", | |
| 945 | .{answering.source.actionName()}, | |
| 946 | ); | |
| 947 | settle(answering, cancel_answer); | |
| 948 | } | |
| 949 | ||
| 950 | /// `[picker dismissViewControllerAnimated:YES completion:nil]`. | |
| 951 | /// | |
| 952 | /// `resolved` is the selector captured at dispatch when there is a call to | |
| 953 | /// answer. A stray callback has none, so it registers the selector here — that | |
| 954 | /// is not a reply path, so a failure is loggable rather than something a | |
| 955 | /// caller could be told about either way. | |
| 956 | /// | |
| 957 | /// `?Id` is `??*anyopaque`, and *both* nulls have to reach the registration | |
| 958 | /// below. The outer one is "no call was waiting, so nothing was captured"; the | |
| 959 | /// inner one is a captured `Sels` whose `dismiss` is itself null. A single | |
| 960 | /// `orelse` only unwraps the outer one, and would then hand `objc_msgSend` a | |
| 961 | /// nil selector — a crash on a live picker rather than the miss this branch is | |
| 962 | /// written for. So it is flattened first. | |
| 963 | fn dismissPresented(picker: Id, resolved: ?Id) void { | |
| 964 | if (!is_darwin) return; | |
| 965 | ||
| 966 | const sel = flattenSelector(resolved) orelse | |
| 967 | (objc.sel_registerName("dismissViewControllerAnimated:completion:") orelse { | |
| 968 | std.log.err( | |
| 969 | "image picker: dismissViewControllerAnimated:completion: would not register; " ++ | |
| 970 | "the picker stays on screen", | |
| 971 | .{}, | |
| 972 | ); | |
| 973 | return; | |
| 974 | }); | |
| 975 | ||
| 976 | const DismissFn = *const fn (Id, Id, bool, Id) callconv(.c) void; | |
| 977 | const dismiss: DismissFn = @ptrCast(&objc.objc_msgSend); | |
| 978 | dismiss(picker, sel, true, null); | |
| 979 | } | |
| 980 | ||
| 981 | /// Collapse `??*anyopaque` to `?*anyopaque` so one `orelse` covers both nulls. | |
| 982 | /// | |
| 983 | /// Split out only so the collapse is assertable on a host — the bug it prevents | |
| 984 | /// (a nil selector reaching `objc_msgSend`) has no observable form short of a | |
| 985 | /// device crash. | |
| 986 | fn flattenSelector(resolved: ?Id) Id { | |
| 987 | return if (resolved) |inner| inner else null; | |
| 988 | } | |
| 989 | ||
| 990 | /// `CGSize` — two `CGFloat`s, 16 bytes. | |
| 991 | /// | |
| 992 | /// Spelled locally rather than named from `objc_runtime`, on the | |
| 993 | /// `bridge_mobile_location.Coord` precedent, so the layout is visible at the | |
| 994 | /// call site. Returned through the regular `objc_msgSend`: `objc_msgSend_stret` | |
| 995 | /// is for structs larger than this and does not exist on arm64 at all. | |
| 996 | const Size = extern struct { width: f64, height: f64 }; | |
| 997 | ||
| 998 | /// Turn the picker's info dictionary into the values the reply is built from. | |
| 999 | /// | |
| 1000 | /// The returned `base64` is owned by the caller. Every Objective-C result is | |
| 1001 | /// guarded, and each nil is a distinct error rather than a zero: Swift's | |
| 1002 | /// `else { rejectCallback(…, "Failed to process image") }` covers all of them, | |
| 1003 | /// and a plausible-looking empty photo is exactly the fabrication that must not | |
| 1004 | /// happen. | |
| 1005 | /// | |
| 1006 | /// The `NSData` is autoreleased and consumed synchronously here — encoded | |
| 1007 | /// before this function returns — so no ownership is taken of it. | |
| 1008 | fn readPhoto(info: Id, work: Work, allocator: std.mem.Allocator) !Photo { | |
| 1009 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 1010 | if (info == null) return error.NoPickerInfo; | |
| 1011 | ||
| 1012 | const ObjectForKeyFn = *const fn (Id, Id, Id) callconv(.c) Id; | |
| 1013 | const object_for_key: ObjectForKeyFn = @ptrCast(&objc.objc_msgSend); | |
| 1014 | const image = object_for_key(info, work.sels.object_for_key, work.original_image_key) orelse | |
| 1015 | return error.NoOriginalImage; | |
| 1016 | ||
| 1017 | // Read before the encode, so the dimensions describe the same UIImage the | |
| 1018 | // bytes came from. | |
| 1019 | const SizeFn = *const fn (Id, Id) callconv(.c) Size; | |
| 1020 | const size_fn: SizeFn = @ptrCast(&objc.objc_msgSend); | |
| 1021 | const size = size_fn(image, work.sels.size); | |
| 1022 | ||
| 1023 | const data = work.jpeg(image, jpeg_compression_quality) orelse return error.JpegEncodingFailed; | |
| 1024 | ||
| 1025 | const LengthFn = *const fn (Id, Id) callconv(.c) c_ulong; | |
| 1026 | const length_fn: LengthFn = @ptrCast(&objc.objc_msgSend); | |
| 1027 | const length: usize = @intCast(length_fn(data, work.sels.length)); | |
| 1028 | if (length == 0) return error.EmptyJpegData; | |
| 1029 | ||
| 1030 | const BytesFn = *const fn (Id, Id) callconv(.c) ?[*]const u8; | |
| 1031 | const bytes_fn: BytesFn = @ptrCast(&objc.objc_msgSend); | |
| 1032 | const bytes = bytes_fn(data, work.sels.bytes) orelse return error.EmptyJpegData; | |
| 1033 | ||
| 1034 | // Encoded once, in Zig, over the raw buffer. The alternative, | |
| 1035 | // `-base64EncodedStringWithOptions:`, would allocate a multi-megabyte | |
| 1036 | // `NSString` that then has to be copied out again; this is the one copy in | |
| 1037 | // this path that can actually be removed. | |
| 1038 | const encoder = std.base64.standard.Encoder; | |
| 1039 | const out = try allocator.alloc(u8, encoder.calcSize(length)); | |
| 1040 | errdefer allocator.free(out); | |
| 1041 | _ = encoder.encode(out, bytes[0..length]); | |
| 1042 | ||
| 1043 | return .{ .base64 = out, .width = size.width, .height = size.height }; | |
| 1044 | } | |
| 1045 | ||
| 1046 | // ============================================================================= | |
| 1047 | // Tests — host-only. | |
| 1048 | // | |
| 1049 | // Everything that decides page-visible bytes is pure and pinned here: routing | |
| 1050 | // in both directions, the five keys and their order, the duplicated payload, | |
| 1051 | // integral dimensions, the cancel-is-a-rejection decision, and the concurrency | |
| 1052 | // policy that is the whole reason a modal needs state at all. | |
| 1053 | // | |
| 1054 | // Nothing here presents a picker or touches UIKit. On a macOS runner | |
| 1055 | // `objc_getClass("UIImagePickerController")` is null and the test binary has no | |
| 1056 | // Info.plist, so the config gate refuses first — which is what makes the file | |
| 1057 | // safe to run. The Objective-C paths that *are* exercised for real are the ones | |
| 1058 | // with no device behind them: selector resolution and delegate registration. | |
| 1059 | // ============================================================================= | |
| 1060 | ||
| 1061 | const testing = std.testing; | |
| 1062 | ||
| 1063 | test "the declared actions are the ones the handler serves" { | |
| 1064 | try testing.expectEqual(@as(usize, 2), capability_actions.len); | |
| 1065 | try testing.expectEqualStrings(A.open_camera, capability_actions[0].name); | |
| 1066 | try testing.expectEqualStrings(A.pick_image, capability_actions[1].name); | |
| 1067 | ||
| 1068 | for (capability_actions) |decl| { | |
| 1069 | // A `.result` whose handler never replies parks the caller on an untimed | |
| 1070 | // promise; a `.none` that is awaited resolves immediately and means | |
| 1071 | // nothing. Swift settles both of these, so both are `.result`. | |
| 1072 | try testing.expectEqual(capabilities.Reply.result, decl.reply); | |
| 1073 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); | |
| 1074 | // `.live` with a reason would be a contradiction the manifest shows apps. | |
| 1075 | try testing.expect(decl.reason == null); | |
| 1076 | } | |
| 1077 | } | |
| 1078 | ||
| 1079 | test "the action names match the Swift case labels exactly" { | |
| 1080 | // The conformance ratchet compares these against the `case "…":` labels in | |
| 1081 | // `CraftApp.swift` in both directions; a prettier spelling would read as | |
| 1082 | // Zig serving an action the spec does not have. | |
| 1083 | try testing.expectEqualStrings("openCamera", A.open_camera); | |
| 1084 | try testing.expectEqualStrings("pickImage", A.pick_image); | |
| 1085 | } | |
| 1086 | ||
| 1087 | test "every declared action is one the dispatcher routes" { | |
| 1088 | for (capability_actions) |decl| { | |
| 1089 | if (routeFor(decl.name) == null) { | |
| 1090 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 1091 | return error.DeclaredActionDoesNotRoute; | |
| 1092 | } | |
| 1093 | } | |
| 1094 | } | |
| 1095 | ||
| 1096 | test "every route the dispatcher has is a declared action" { | |
| 1097 | // The other direction: a route with a handler and no declaration is an | |
| 1098 | // action the page can call and the manifest denies exists. Each declaration | |
| 1099 | // must claim a *distinct* route — counting alone would let two rows share | |
| 1100 | // one route while another went undeclared. | |
| 1101 | const sources = std.enums.values(Source); | |
| 1102 | var claimed = std.mem.zeroes([std.enums.values(Source).len]bool); | |
| 1103 | ||
| 1104 | for (capability_actions) |decl| { | |
| 1105 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 1106 | var slot: usize = sources.len; | |
| 1107 | for (sources, 0..) |candidate, i| { | |
| 1108 | if (candidate == route) slot = i; | |
| 1109 | } | |
| 1110 | if (slot == sources.len) return error.RouteIsNotEnumerated; | |
| 1111 | if (claimed[slot]) { | |
| 1112 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 1113 | return error.TwoDeclarationsShareARoute; | |
| 1114 | } | |
| 1115 | claimed[slot] = true; | |
| 1116 | } | |
| 1117 | ||
| 1118 | for (claimed, 0..) |taken, slot| { | |
| 1119 | if (!taken) { | |
| 1120 | std.debug.print( | |
| 1121 | "route {s} has a handler but no capability_actions row\n", | |
| 1122 | .{@tagName(sources[slot])}, | |
| 1123 | ); | |
| 1124 | return error.RouteNotDeclared; | |
| 1125 | } | |
| 1126 | } | |
| 1127 | } | |
| 1128 | ||
| 1129 | test "the two actions differ only in the source type they set" { | |
| 1130 | // The whole design of this module in two assertions. The raw values are | |
| 1131 | // UIKit's: PhotoLibrary = 0, Camera = 1, SavedPhotosAlbum = 2 (unused). | |
| 1132 | try testing.expectEqual(Source.camera, routeFor("openCamera").?); | |
| 1133 | try testing.expectEqual(Source.photo_library, routeFor("pickImage").?); | |
| 1134 | try testing.expectEqual(@as(c_long, 1), @backingInt(Source.camera)); | |
| 1135 | try testing.expectEqual(@as(c_long, 0), @backingInt(Source.photo_library)); | |
| 1136 | ||
| 1137 | // And each knows its own name back, because every log line and every | |
| 1138 | // `ios_async.acquire` is keyed on it — a swap here would correlate a reply | |
| 1139 | // to the wrong action. | |
| 1140 | try testing.expectEqualStrings(A.open_camera, Source.camera.actionName()); | |
| 1141 | try testing.expectEqualStrings(A.pick_image, Source.photo_library.actionName()); | |
| 1142 | } | |
| 1143 | ||
| 1144 | test "an action the namespace does not serve is reported, not ignored" { | |
| 1145 | var bridge = ImagePickerBridge.init(testing.allocator); | |
| 1146 | defer bridge.deinit(); | |
| 1147 | ||
| 1148 | try testing.expectError( | |
| 1149 | bridge_error.BridgeError.UnknownAction, | |
| 1150 | bridge.handleMessage("noSuchAction", "{}"), | |
| 1151 | ); | |
| 1152 | ||
| 1153 | // Casing is how a real typo arrives, and a miss does not fail loudly: | |
| 1154 | // `ios_dispatch` reads UnknownAction as "not mine" and hands the action to | |
| 1155 | // the Swift shim, so a typo silently keeps the old implementation. | |
| 1156 | for ([_][]const u8{ "opencamera", "OpenCamera", "pickimage", "PickImage" }) |typo| { | |
| 1157 | try testing.expectError( | |
| 1158 | bridge_error.BridgeError.UnknownAction, | |
| 1159 | bridge.handleMessage(typo, "{}"), | |
| 1160 | ); | |
| 1161 | } | |
| 1162 | ||
| 1163 | // The neighbours that share Swift's Coordinator delegate but are not this | |
| 1164 | // module's. Two modules answering one action would make `ios_dispatch`'s | |
| 1165 | // first-match routing order-dependent — and `startVideoRecording` in | |
| 1166 | // particular must keep reaching the shim, whose delegate crosstalk this | |
| 1167 | // module exists partly to avoid inheriting. | |
| 1168 | for ([_][]const u8{ | |
| 1169 | "startVideoRecording", | |
| 1170 | "stopVideoRecording", | |
| 1171 | "takeScreenshot", | |
| 1172 | "share", | |
| 1173 | }) |action| { | |
| 1174 | try testing.expectError( | |
| 1175 | bridge_error.BridgeError.UnknownAction, | |
| 1176 | bridge.handleMessage(action, "{}"), | |
| 1177 | ); | |
| 1178 | } | |
| 1179 | } | |
| 1180 | ||
| 1181 | // ----------------------------------------------------------------------------- | |
| 1182 | // Reply shaping. | |
| 1183 | // ----------------------------------------------------------------------------- | |
| 1184 | ||
| 1185 | /// A JSON number as an `f64`, whatever variant `std.json` chose for it. The | |
| 1186 | /// variant is a property of the *bytes*: `{d}` renders 3024 as the integer | |
| 1187 | /// token `3024` (`.integer`) and 3024.5 as `3024.5` (`.float`), so reading | |
| 1188 | /// `.float` directly would panic on the first. | |
| 1189 | fn numberOf(value: std.json.Value) !f64 { | |
| 1190 | return switch (value) { | |
| 1191 | .integer => |i| @floatFromInt(i), | |
| 1192 | .float => |f| f, | |
| 1193 | .number_string => |text| try std.fmt.parseFloat(f64, text), | |
| 1194 | else => error.ReplyFieldIsNotANumber, | |
| 1195 | }; | |
| 1196 | } | |
| 1197 | ||
| 1198 | test "the reply is the five-key object Swift resolves, in a fixed order" { | |
| 1199 | const json = try shapePhoto(testing.allocator, .{ | |
| 1200 | .base64 = "aGVsbG8=", | |
| 1201 | .width = 3024, | |
| 1202 | .height = 4032, | |
| 1203 | }); | |
| 1204 | defer testing.allocator.free(json); | |
| 1205 | ||
| 1206 | // The exact bytes, because the shape is the contract and `PhotoResult` | |
| 1207 | // names all five keys. Integral dimensions carry no fraction: Swift's | |
| 1208 | // `JSONSerialization` writes `3024` for the same CGFloat, and `{d}` agrees. | |
| 1209 | try testing.expectEqualStrings( | |
| 1210 | "{\"base64\":\"aGVsbG8=\"," ++ | |
| 1211 | "\"uri\":\"data:image/jpeg;base64,aGVsbG8=\"," ++ | |
| 1212 | "\"width\":3024,\"height\":4032," ++ | |
| 1213 | "\"mimeType\":\"image/jpeg\"}", | |
| 1214 | json, | |
| 1215 | ); | |
| 1216 | ||
| 1217 | // And it really parses as the object the page consumes, rather than merely | |
| 1218 | // looking like one. | |
| 1219 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1220 | defer parsed.deinit(); | |
| 1221 | const obj = parsed.value.object; | |
| 1222 | try testing.expectEqual(@as(usize, 5), obj.count()); | |
| 1223 | try testing.expectEqualStrings("aGVsbG8=", obj.get("base64").?.string); | |
| 1224 | try testing.expectEqualStrings("image/jpeg", obj.get("mimeType").?.string); | |
| 1225 | try testing.expectEqual(@as(f64, 3024), try numberOf(obj.get("width").?)); | |
| 1226 | try testing.expectEqual(@as(f64, 4032), try numberOf(obj.get("height").?)); | |
| 1227 | } | |
| 1228 | ||
| 1229 | test "the payload is carried twice, raw in base64 and prefixed in uri" { | |
| 1230 | // The duplication is the contract, not an oversight: `normalizePhoto` | |
| 1231 | // passes the object straight through and the SDK's `PhotoResult` reads both | |
| 1232 | // fields. A `uri` that merely repeated `base64` without the prefix, or a | |
| 1233 | // `base64` that carried the prefix, would each break one consumer. | |
| 1234 | const payload = "QUJDREVGRw=="; | |
| 1235 | const json = try shapePhoto(testing.allocator, .{ | |
| 1236 | .base64 = payload, | |
| 1237 | .width = 1, | |
| 1238 | .height = 1, | |
| 1239 | }); | |
| 1240 | defer testing.allocator.free(json); | |
| 1241 | ||
| 1242 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1243 | defer parsed.deinit(); | |
| 1244 | ||
| 1245 | const base64 = parsed.value.object.get("base64").?.string; | |
| 1246 | const uri = parsed.value.object.get("uri").?.string; | |
| 1247 | ||
| 1248 | try testing.expectEqualStrings(payload, base64); | |
| 1249 | try testing.expectEqualStrings(data_uri_prefix ++ payload, uri); | |
| 1250 | try testing.expect(std.mem.startsWith(u8, uri, "data:image/jpeg;base64,")); | |
| 1251 | try testing.expect(!std.mem.startsWith(u8, base64, "data:")); | |
| 1252 | } | |
| 1253 | ||
| 1254 | test "the mime type is the JPEG literal for both actions, whatever was picked" { | |
| 1255 | // The image is re-encoded at `jpeg_compression_quality`, so a PNG or HEIC | |
| 1256 | // chosen from the library still arrives as JPEG. Sniffing the original | |
| 1257 | // type and reporting it would be a different, and wrong, contract. | |
| 1258 | try testing.expectEqualStrings("image/jpeg", mime_type); | |
| 1259 | try testing.expectEqual(@as(f64, 0.8), jpeg_compression_quality); | |
| 1260 | } | |
| 1261 | ||
| 1262 | test "a fractional dimension keeps its fraction rather than being rounded" { | |
| 1263 | // `UIImage.size` is in points and is a CGFloat, so a scaled or cropped | |
| 1264 | // image can be fractional. Truncating would report a size the image does | |
| 1265 | // not have. | |
| 1266 | const json = try shapePhoto(testing.allocator, .{ | |
| 1267 | .base64 = "", | |
| 1268 | .width = 375.5, | |
| 1269 | .height = 812.25, | |
| 1270 | }); | |
| 1271 | defer testing.allocator.free(json); | |
| 1272 | ||
| 1273 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1274 | defer parsed.deinit(); | |
| 1275 | try testing.expectEqual(@as(f64, 375.5), try numberOf(parsed.value.object.get("width").?)); | |
| 1276 | try testing.expectEqual(@as(f64, 812.25), try numberOf(parsed.value.object.get("height").?)); | |
| 1277 | } | |
| 1278 | ||
| 1279 | test "a non-finite dimension is refused rather than printed" { | |
| 1280 | // `inf` and `nan` are not JSON. Printing them would put a bare `inf` inside | |
| 1281 | // the reply script and turn the whole `__craftBridgeResult` call into a | |
| 1282 | // syntax error in the page, with nothing to point at. | |
| 1283 | for ([_]f64{ std.math.inf(f64), -std.math.inf(f64), std.math.nan(f64) }) |bad| { | |
| 1284 | try testing.expectError( | |
| 1285 | bridge_error.BridgeError.InvalidParameter, | |
| 1286 | shapePhoto(testing.allocator, .{ .base64 = "", .width = bad, .height = 1 }), | |
| 1287 | ); | |
| 1288 | try testing.expectError( | |
| 1289 | bridge_error.BridgeError.InvalidParameter, | |
| 1290 | shapePhoto(testing.allocator, .{ .base64 = "", .width = 1, .height = bad }), | |
| 1291 | ); | |
| 1292 | } | |
| 1293 | } | |
| 1294 | ||
| 1295 | test "the encoded payload needs no JSON escaping" { | |
| 1296 | // `shapePhoto` appends the payload straight into a JSON string, which is | |
| 1297 | // only safe because of what the encoder can emit. Checked rather than | |
| 1298 | // asserted in a comment: if the encoder is ever swapped for one with a | |
| 1299 | // different alphabet — url-safe uses `-` and `_`, which are also fine, but | |
| 1300 | // a future one might not be — this fails here rather than on a device with | |
| 1301 | // a reply the page cannot parse. | |
| 1302 | const encoder = std.base64.standard.Encoder; | |
| 1303 | for (std.base64.standard_alphabet_chars) |c| { | |
| 1304 | try testing.expect(c != '"' and c != '\\' and c >= 0x20); | |
| 1305 | } | |
| 1306 | try testing.expect(encoder.pad_char != null); | |
| 1307 | const pad = encoder.pad_char.?; | |
| 1308 | try testing.expect(pad != '"' and pad != '\\' and pad >= 0x20); | |
| 1309 | } | |
| 1310 | ||
| 1311 | test "a real encode round-trips into a reply the page can parse" { | |
| 1312 | // The bytes chosen include 0xFF 0xD8 0xFF, a real JPEG SOI, and a run that | |
| 1313 | // forces padding — the two things that make an encoder's output visibly | |
| 1314 | // wrong if the length arithmetic is off. | |
| 1315 | const raw = [_]u8{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F' }; | |
| 1316 | const encoder = std.base64.standard.Encoder; | |
| 1317 | ||
| 1318 | const encoded = try testing.allocator.alloc(u8, encoder.calcSize(raw.len)); | |
| 1319 | defer testing.allocator.free(encoded); | |
| 1320 | _ = encoder.encode(encoded, &raw); | |
| 1321 | ||
| 1322 | const json = try shapePhoto(testing.allocator, .{ | |
| 1323 | .base64 = encoded, | |
| 1324 | .width = 10, | |
| 1325 | .height = 10, | |
| 1326 | }); | |
| 1327 | defer testing.allocator.free(json); | |
| 1328 | ||
| 1329 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 1330 | defer parsed.deinit(); | |
| 1331 | ||
| 1332 | const decoder = std.base64.standard.Decoder; | |
| 1333 | const field = parsed.value.object.get("base64").?.string; | |
| 1334 | const back = try testing.allocator.alloc(u8, try decoder.calcSizeForSlice(field)); | |
| 1335 | defer testing.allocator.free(back); | |
| 1336 | try decoder.decode(back, field); | |
| 1337 | try testing.expectEqualSlices(u8, &raw, back); | |
| 1338 | } | |
| 1339 | ||
| 1340 | // ----------------------------------------------------------------------------- | |
| 1341 | // Which callback resolves and which rejects. | |
| 1342 | // ----------------------------------------------------------------------------- | |
| 1343 | ||
| 1344 | test "a cancel rejects rather than resolving anything" { | |
| 1345 | // The single most important shape in this file. `craft-bridge.js` resolves | |
| 1346 | // with `payload || {}`, so a `null`, `false` or `{}` result for a cancel | |
| 1347 | // would run the app's `then` branch — `normalizePhoto` would even hand it a | |
| 1348 | // well-formed `PhotoResult` with an empty payload — for a picture the user | |
| 1349 | // declined to take. Swift rejects; so does this. | |
| 1350 | try testing.expect(cancel_answer == .reject); | |
| 1351 | try testing.expect(cancel_answer != .photo); | |
| 1352 | } | |
| 1353 | ||
| 1354 | test "an image that will not encode rejects, and only a complete one resolves" { | |
| 1355 | // Swift's `if let image = …, let imageData = …` — both halves required, | |
| 1356 | // either one missing is `"Failed to process image"`. A reply built from a | |
| 1357 | // missing half would be `{"base64":"","width":0,"height":0,…}`, which is | |
| 1358 | // indistinguishable from a real photo of nothing, and `normalizePhoto` | |
| 1359 | // would pass it straight through to the app's `then`. | |
| 1360 | try testing.expect(finishAnswer(null) == .reject); | |
| 1361 | ||
| 1362 | const shaped = "{\"base64\":\"\",\"uri\":\"data:image/jpeg;base64,\",\"width\":1,\"height\":1,\"mimeType\":\"image/jpeg\"}"; | |
| 1363 | switch (finishAnswer(shaped)) { | |
| 1364 | .photo => |json| try testing.expectEqualStrings(shaped, json), | |
| 1365 | .reject => return error.AShapedReplyWasRejected, | |
| 1366 | } | |
| 1367 | } | |
| 1368 | ||
| 1369 | // ----------------------------------------------------------------------------- | |
| 1370 | // One picker, one caller. | |
| 1371 | // ----------------------------------------------------------------------------- | |
| 1372 | ||
| 1373 | /// A `Work` with nothing resolved. Safe in these tests because none of them | |
| 1374 | /// reaches a delegate callback — they exercise the slot, not the picker. | |
| 1375 | const empty_work = Work{ | |
| 1376 | .sels = .{ | |
| 1377 | .object_for_key = null, | |
| 1378 | .size = null, | |
| 1379 | .bytes = null, | |
| 1380 | .length = null, | |
| 1381 | .dismiss = null, | |
| 1382 | }, | |
| 1383 | .original_image_key = null, | |
| 1384 | .jpeg = &noopJpeg, | |
| 1385 | }; | |
| 1386 | ||
| 1387 | fn noopJpeg(_: Id, _: f64) callconv(.c) Id { | |
| 1388 | return null; | |
| 1389 | } | |
| 1390 | ||
| 1391 | test "the slot hands the delegate its ticket once, and clearing it frees the picker" { | |
| 1392 | _ = takePending(); | |
| 1393 | ||
| 1394 | const ticket = ios_async.Ticket{ .index = 6, .generation = 17 }; | |
| 1395 | try testing.expect(publish(.{ .ticket = ticket, .source = .camera, .work = empty_work }) == .published); | |
| 1396 | ||
| 1397 | const taken = takePending() orelse return error.PublishedCallWentMissing; | |
| 1398 | try testing.expectEqual(@as(u5, 6), taken.ticket.index); | |
| 1399 | try testing.expectEqual(@as(u32, 17), taken.ticket.generation); | |
| 1400 | try testing.expectEqual(Source.camera, taken.source); | |
| 1401 | ||
| 1402 | // Taken means gone: a double-firing delegate must not reply twice, and the | |
| 1403 | // next caller must find the picker free. | |
| 1404 | try testing.expect(takePending() == null); | |
| 1405 | try testing.expect(busyWith() == null); | |
| 1406 | } | |
| 1407 | ||
| 1408 | test "a second request while a picker is presented is refused, and the first is untouched" { | |
| 1409 | // The concurrency policy, and the one place it deliberately differs from | |
| 1410 | // `bridge_mobile_location`: there the framework cancels the earlier request | |
| 1411 | // so the displaced caller *must* be rejected; here the first picker is | |
| 1412 | // still on screen and will answer, so replacing it would strand a promise | |
| 1413 | // that was about to settle. | |
| 1414 | _ = takePending(); | |
| 1415 | ||
| 1416 | const first = ios_async.Ticket{ .index = 2, .generation = 3 }; | |
| 1417 | const second = ios_async.Ticket{ .index = 9, .generation = 4 }; | |
| 1418 | ||
| 1419 | try testing.expect(publish(.{ .ticket = first, .source = .camera, .work = empty_work }) == .published); | |
| 1420 | ||
| 1421 | // The refusal names who is holding it, and it is the *second* caller that | |
| 1422 | // is refused. | |
| 1423 | const refused = publish(.{ .ticket = second, .source = .photo_library, .work = empty_work }); | |
| 1424 | switch (refused) { | |
| 1425 | .published => return error.SecondRequestSilentlyReplacedTheFirst, | |
| 1426 | .busy => |holder| try testing.expectEqual(Source.camera, holder), | |
| 1427 | } | |
| 1428 | try testing.expectEqual(Source.camera, busyWith().?); | |
| 1429 | ||
| 1430 | // And it is a rejection the caller can see, never silence. | |
| 1431 | try testing.expectEqual( | |
| 1432 | bridge_error.BridgeError.InvalidParameter, | |
| 1433 | busyRefusal(A.pick_image, .camera), | |
| 1434 | ); | |
| 1435 | ||
| 1436 | // The survivor is the first, unchanged — the whole point. | |
| 1437 | const survivor = takePending() orelse return error.FirstCallWasDropped; | |
| 1438 | try testing.expectEqual(@as(u5, 2), survivor.ticket.index); | |
| 1439 | try testing.expectEqual(@as(u32, 3), survivor.ticket.generation); | |
| 1440 | try testing.expectEqual(Source.camera, survivor.source); | |
| 1441 | ||
| 1442 | // With the picker free, the next caller is admitted. | |
| 1443 | try testing.expect(publish(.{ .ticket = second, .source = .photo_library, .work = empty_work }) == .published); | |
| 1444 | _ = takePending(); | |
| 1445 | } | |
| 1446 | ||
| 1447 | test "a delegate callback with no call recorded touches no slot" { | |
| 1448 | // A late or duplicate fire must not reach `deliverJson`/`deliverError` at | |
| 1449 | // all. With the slot empty there is no ticket to reply with, and all three | |
| 1450 | // methods return without touching the pool — asserted by the slot staying | |
| 1451 | // empty rather than by observing a reply that must not happen. | |
| 1452 | _ = takePending(); | |
| 1453 | ||
| 1454 | craftIOSImagePickerDidFinish(null, null, null, null); | |
| 1455 | try testing.expect(takePending() == null); | |
| 1456 | ||
| 1457 | craftIOSImagePickerDidCancel(null, null, null); | |
| 1458 | try testing.expect(takePending() == null); | |
| 1459 | ||
| 1460 | craftIOSImagePickerDidDismiss(null, null, null); | |
| 1461 | try testing.expect(takePending() == null); | |
| 1462 | ||
| 1463 | try testing.expect(busyWith() == null); | |
| 1464 | } | |
| 1465 | ||
| 1466 | test "the pool refusal is an error the caller sees, not a dropped call" { | |
| 1467 | try testing.expectEqual( | |
| 1468 | bridge_error.BridgeError.InvalidParameter, | |
| 1469 | poolFull(A.open_camera), | |
| 1470 | ); | |
| 1471 | } | |
| 1472 | ||
| 1473 | // ----------------------------------------------------------------------------- | |
| 1474 | // Platform and gate. | |
| 1475 | // ----------------------------------------------------------------------------- | |
| 1476 | ||
| 1477 | test "off Darwin the handler refuses rather than pretending to present" { | |
| 1478 | if (is_darwin) return error.SkipZigTest; | |
| 1479 | ||
| 1480 | var bridge = ImagePickerBridge.init(testing.allocator); | |
| 1481 | defer bridge.deinit(); | |
| 1482 | ||
| 1483 | for ([_][]const u8{ A.open_camera, A.pick_image }) |action| { | |
| 1484 | try testing.expectError(error.UnsupportedPlatform, bridge.handleMessage(action, "{}")); | |
| 1485 | } | |
| 1486 | try testing.expectError(error.UnsupportedPlatform, requireConfigured(.camera)); | |
| 1487 | try testing.expectError(error.UnsupportedPlatform, requireConfigured(.photo_library)); | |
| 1488 | try testing.expectError(error.UnsupportedPlatform, Sels.resolve()); | |
| 1489 | try testing.expectError(error.UnsupportedPlatform, Work.resolve()); | |
| 1490 | try testing.expectError(error.UnsupportedPlatform, ensureDelegate()); | |
| 1491 | } | |
| 1492 | ||
| 1493 | test "without the usage descriptions the gate refuses before any picker exists" { | |
| 1494 | if (!is_darwin) return error.SkipZigTest; | |
| 1495 | ||
| 1496 | // The host runner is a bare binary with no Info.plist, which is exactly the | |
| 1497 | // "this app was not built with the camera enabled" case the gate is for. It | |
| 1498 | // is also what keeps this file safe to run: everything past the gate would | |
| 1499 | // look for UIKit, and on a runner that had it, present a real picker. | |
| 1500 | if (requireConfigured(.photo_library)) |_| { | |
| 1501 | // A runner that does carry the key (tests hosted inside a real app) | |
| 1502 | // would take the live path below, so skip rather than present. | |
| 1503 | return error.SkipZigTest; | |
| 1504 | } else |err| switch (err) { | |
| 1505 | bridge_error.BridgeError.PermissionDenied => { | |
| 1506 | var bridge = ImagePickerBridge.init(testing.allocator); | |
| 1507 | defer bridge.deinit(); | |
| 1508 | ||
| 1509 | for ([_][]const u8{ A.open_camera, A.pick_image }) |action| { | |
| 1510 | try testing.expectError( | |
| 1511 | bridge_error.BridgeError.PermissionDenied, | |
| 1512 | bridge.handleMessage(action, "{}"), | |
| 1513 | ); | |
| 1514 | } | |
| 1515 | ||
| 1516 | // The refusal happens before a slot is leased and before anything | |
| 1517 | // is published: an unreleased lease narrows the pool for every | |
| 1518 | // later call, and a published call with no picker behind it would | |
| 1519 | // refuse every request for the life of the process. | |
| 1520 | try testing.expect(takePending() == null); | |
| 1521 | try testing.expect(busyWith() == null); | |
| 1522 | }, | |
| 1523 | else => return err, | |
| 1524 | } | |
| 1525 | } | |
| 1526 | ||
| 1527 | test "the payload is ignored, not parsed" { | |
| 1528 | if (!is_darwin) return error.SkipZigTest; | |
| 1529 | // Real dispatches, so this needs the process the gate stops. | |
| 1530 | if (requireConfigured(.photo_library)) |_| return error.SkipZigTest else |_| {} | |
| 1531 | ||
| 1532 | // Both injected methods are declared `function()` and the Swift dispatcher | |
| 1533 | // reads nothing out of `body`, so a payload that is not even JSON must | |
| 1534 | // reach exactly the same outcome as `{}`. If it did not, this module would | |
| 1535 | // have invented a failure the shim does not have. | |
| 1536 | var bridge = ImagePickerBridge.init(testing.allocator); | |
| 1537 | defer bridge.deinit(); | |
| 1538 | ||
| 1539 | for ([_][]const u8{ A.open_camera, A.pick_image }) |action| { | |
| 1540 | const empty = bridge.handleMessage(action, "{}"); | |
| 1541 | const junk = bridge.handleMessage(action, "{not json"); | |
| 1542 | const missing = bridge.handleMessage(action, ""); | |
| 1543 | // The options the SDK types but the page never sends. | |
| 1544 | const options = bridge.handleMessage( | |
| 1545 | action, | |
| 1546 | "{\"quality\":0.5,\"maxWidth\":1024,\"camera\":\"front\",\"allowsEditing\":true}", | |
| 1547 | ); | |
| 1548 | ||
| 1549 | try testing.expectEqual(empty, junk); | |
| 1550 | try testing.expectEqual(empty, missing); | |
| 1551 | try testing.expectEqual(empty, options); | |
| 1552 | ||
| 1553 | if (empty) |_| {} else |err| { | |
| 1554 | try testing.expect(err != bridge_error.BridgeError.InvalidJSON); | |
| 1555 | try testing.expect(err != bridge_error.BridgeError.MissingData); | |
| 1556 | } | |
| 1557 | } | |
| 1558 | ||
| 1559 | try testing.expect(takePending() == null); | |
| 1560 | } | |
| 1561 | ||
| 1562 | test "every selector the delegate needs resolves on a real runtime" { | |
| 1563 | if (!is_darwin) return error.SkipZigTest; | |
| 1564 | ||
| 1565 | // `sel_registerName` interns a name whether or not any class implements it, | |
| 1566 | // so this touches no framework and presents nothing. What it catches is a | |
| 1567 | // typo in one of the five spellings, which would otherwise surface as a | |
| 1568 | // rejected pick — or a picker that cannot be dismissed — on a device. | |
| 1569 | const sels = try Sels.resolve(); | |
| 1570 | inline for (comptime std.meta.fieldNames(Sels)) |name| { | |
| 1571 | if (@field(sels, name) == null) { | |
| 1572 | std.debug.print("selector for Sels.{s} did not resolve\n", .{name}); | |
| 1573 | return error.SelectorDidNotResolve; | |
| 1574 | } | |
| 1575 | } | |
| 1576 | ||
| 1577 | // Distinct, or two different reads would come from one property. | |
| 1578 | try testing.expect(sels.bytes != sels.length); | |
| 1579 | try testing.expect(sels.size != sels.object_for_key); | |
| 1580 | try testing.expect(sels.dismiss != sels.size); | |
| 1581 | } | |
| 1582 | ||
| 1583 | test "the delegate class registers under its own name, idempotently" { | |
| 1584 | if (!is_darwin) return error.SkipZigTest; | |
| 1585 | ||
| 1586 | // No UIKit is involved: this allocates an NSObject subclass and one | |
| 1587 | // instance. It is the load-bearing half of the delegate — a class that does | |
| 1588 | // not register, or that adopts another module's IMPs, is a picker that | |
| 1589 | // never answers. | |
| 1590 | const first = try ensureDelegate(); | |
| 1591 | const second = try ensureDelegate(); | |
| 1592 | ||
| 1593 | // The same live instance, not merely two non-null answers: the instance is | |
| 1594 | // the strong reference `UIImagePickerController`'s weak `delegate` depends | |
| 1595 | // on, and a second one would leave the first free to be released. | |
| 1596 | try testing.expectEqual(first, second); | |
| 1597 | ||
| 1598 | const cls = objc.object_getClass(first) orelse return error.DelegateHasNoClass; | |
| 1599 | try testing.expectEqualStrings(delegate_class_name, std.mem.span(objc.class_getName(cls))); | |
| 1600 | ||
| 1601 | // All three methods attached. UIKit dispatches through | |
| 1602 | // `respondsToSelector:`, so a method that failed to attach is a callback | |
| 1603 | // that is simply never made — silence, on an untimed promise. | |
| 1604 | const sel_responds = objc.sel_registerName("respondsToSelector:") orelse | |
| 1605 | return error.SelectorNotFound; | |
| 1606 | const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool; | |
| 1607 | const responds: RespondsFn = @ptrCast(&objc.objc_msgSend); | |
| 1608 | ||
| 1609 | for (delegateMethods()) |method| { | |
| 1610 | const sel = objc.sel_registerName(method.selector.ptr) orelse return error.SelectorNotFound; | |
| 1611 | if (!responds(first, sel_responds, sel)) { | |
| 1612 | std.debug.print("the delegate does not respond to {s}\n", .{method.selector}); | |
| 1613 | return error.DelegateMissingMethod; | |
| 1614 | } | |
| 1615 | } | |
| 1616 | } | |
| 1617 | ||
| 1618 | test "the delegate methods carry the encodings their signatures actually have" { | |
| 1619 | // A wrong encoding registers, dispatches, and then reads arguments out of | |
| 1620 | // the wrong registers: no compile error, often no crash, just garbage | |
| 1621 | // pointers. Naming them is the only defence, so the pairing is pinned. | |
| 1622 | const methods = delegateMethods(); | |
| 1623 | try testing.expectEqual(@as(usize, 3), methods.len); | |
| 1624 | ||
| 1625 | try testing.expectEqualStrings( | |
| 1626 | "imagePickerController:didFinishPickingMediaWithInfo:", | |
| 1627 | methods[0].selector, | |
| 1628 | ); | |
| 1629 | // Two object arguments — the picker and the info dictionary. | |
| 1630 | try testing.expectEqualStrings("v@:@@", methods[0].types); | |
| 1631 | ||
| 1632 | try testing.expectEqualStrings( | |
| 1633 | "imagePickerControllerDidCancel:", | |
| 1634 | methods[1].selector, | |
| 1635 | ); | |
| 1636 | // One object argument — the picker. | |
| 1637 | try testing.expectEqualStrings("v@:@", methods[1].types); | |
| 1638 | ||
| 1639 | try testing.expectEqualStrings( | |
| 1640 | "presentationControllerDidDismiss:", | |
| 1641 | methods[2].selector, | |
| 1642 | ); | |
| 1643 | try testing.expectEqualStrings("v@:@", methods[2].types); | |
| 1644 | ||
| 1645 | // Taken from `ios_delegate.enc` rather than spelled here, so a caller | |
| 1646 | // cannot drift from the shared vocabulary. | |
| 1647 | try testing.expectEqualStrings(ios_delegate.enc.void_two_objects, methods[0].types); | |
| 1648 | try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[1].types); | |
| 1649 | try testing.expectEqualStrings(ios_delegate.enc.void_one_object, methods[2].types); | |
| 1650 | } | |
| 1651 | ||
| 1652 | test "a captured dismiss selector that is itself null falls through to registration" { | |
| 1653 | // `dismissPresented` takes `?Id`, which is `??*anyopaque`: the callbacks | |
| 1654 | // build it as `if (call) |c| c.work.sels.dismiss else null`, so a stray | |
| 1655 | // callback yields the *outer* null and a call yields `.some(selector)`. | |
| 1656 | // Nothing stops that inner selector from being null — `empty_work` is | |
| 1657 | // exactly that shape — and a lone `orelse` would unwrap `.some(null)` into | |
| 1658 | // a nil selector handed straight to `objc_msgSend`, which is a crash on a | |
| 1659 | // live picker rather than the missing-selector branch. | |
| 1660 | const outer_null: ?Id = null; | |
| 1661 | const inner_null: ?Id = @as(Id, null); | |
| 1662 | ||
| 1663 | // The two really are distinct values at the type level, which is the whole | |
| 1664 | // trap: only one of them is `null` to `orelse`. | |
| 1665 | try testing.expect(outer_null == null); | |
| 1666 | try testing.expect(inner_null != null); | |
| 1667 | try testing.expect(inner_null.? == null); | |
| 1668 | ||
| 1669 | // Flattened, both mean "no selector was captured". | |
| 1670 | try testing.expect(flattenSelector(outer_null) == null); | |
| 1671 | try testing.expect(flattenSelector(inner_null) == null); | |
| 1672 | try testing.expect(flattenSelector(empty_work.sels.dismiss) == null); | |
| 1673 | } | |
| 1674 | ||
| 1675 | test "the delegate class name cannot collide with another module's" { | |
| 1676 | // `bridge_location.zig` registers `CraftLocationDelegate` for the desktop | |
| 1677 | // and `bridge_mobile_location.zig` registers `CraftIOSLocationDelegate`, | |
| 1678 | // and all of them are compiled into the same host test binary. Every one of | |
| 1679 | // these looks its class up with `objc_getClass` first, so whichever | |
| 1680 | // registered second would silently adopt the other's IMPs. | |
| 1681 | try testing.expectEqualStrings("CraftIOSImagePickerDelegate", delegate_class_name); | |
| 1682 | for ([_][]const u8{ | |
| 1683 | "CraftLocationDelegate", | |
| 1684 | "CraftIOSLocationDelegate", | |
| 1685 | "CraftScriptMessageHandler", | |
| 1686 | }) |taken| { | |
| 1687 | try testing.expect(!std.mem.eql(u8, delegate_class_name, taken)); | |
| 1688 | } | |
| 1689 | } | |
| @@ -355,7 +355,7 @@ fn failFromCallback(request_id: i64, action: []const u8, err: bridge_error.Bridg | ||
| 355 | 355 | /// The non-Darwin arm is not a stub that pretends to work — it returns |
| 356 | 356 | /// `error.UnsupportedPlatform`, which `ios_dispatch.asBridgeError` turns into |
| 357 | 357 | /// `PLATFORM_NOT_SUPPORTED` for the page. |
| 358 | const uikit = if (is_darwin) struct { | |
| 358 | pub const uikit = if (is_darwin) struct { | |
| 359 | 359 | /// `[[UIApplication sharedApplication] openURL:options:completionHandler:]` |
| 360 | 360 | /// |
| 361 | 361 | /// The modern three-argument selector, never the deprecated one-argument |
| @@ -554,7 +554,7 @@ const uikit = if (is_darwin) struct { | ||
| 554 | 554 | /// draws no arrow. It is emphatically not `UIPopoverArrowDirectionUnknown`, |
| 555 | 555 | /// which is `NSUIntegerMax`: passing that would permit every direction and |
| 556 | 556 | /// point an arrow at the middle of the screen. |
| 557 | fn anchorPopover(activity_vc: objc.id, presenter: objc.id) !void { | |
| 557 | pub fn anchorPopover(activity_vc: objc.id, presenter: objc.id) !void { | |
| 558 | 558 | const sel_popover = objc.sel_registerName("popoverPresentationController") orelse |
| 559 | 559 | return error.SelectorNotFound; |
| 560 | 560 | const popover = objc.msgSendId(activity_vc, sel_popover); |
| @@ -669,7 +669,7 @@ const uikit = if (is_darwin) struct { | ||
| 669 | 669 | /// presenting on a controller that is already presenting something logs |
| 670 | 670 | /// "which is already presenting" and silently does nothing, so the |
| 671 | 671 | /// completion handler never fires and the caller waits out its timeout. |
| 672 | fn topmostViewController() !objc.id { | |
| 672 | pub fn topmostViewController() !objc.id { | |
| 673 | 673 | const webview = ios_dispatch.getWebView() orelse |
| 674 | 674 | return bridge_error.BridgeError.WebViewHandleNotSet; |
| 675 | 675 | if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet; |
| @@ -62,6 +62,10 @@ const Slot = struct { | ||
| 62 | 62 | /// A module-shaped reply, for completions this file cannot shape itself. |
| 63 | 63 | /// Owned by the slot; freed on delivery. Null means use `granted`. |
| 64 | 64 | json: ?[]u8 = null, |
| 65 | /// Which error to report when `failed`. Defaults to the generic one, so | |
| 66 | /// `deliverError` keeps its meaning and only a caller that knows better | |
| 67 | /// has to say so. | |
| 68 | fail_code: bridge_error.BridgeError = bridge_error.BridgeError.NativeCallFailed, | |
| 65 | 69 | /// Set when the reply could not be produced at all. Distinct from a null |
| 66 | 70 | /// `json`, which legitimately means "this was a BOOL completion" — without |
| 67 | 71 | /// the flag, a failure falls through to the BOOL wording and resolves |
| @@ -230,6 +234,7 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 230 | 234 | var granted = false; |
| 231 | 235 | var json: ?[]u8 = null; |
| 232 | 236 | var failed = false; |
| 237 | var fail_code = bridge_error.BridgeError.NativeCallFailed; | |
| 233 | 238 | { |
| 234 | 239 | slots_mutex.lock(); |
| 235 | 240 | defer slots_mutex.unlock(); |
| @@ -241,8 +246,10 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 241 | 246 | granted = slot.granted; |
| 242 | 247 | json = slot.json; |
| 243 | 248 | failed = slot.failed; |
| 249 | fail_code = slot.fail_code; | |
| 244 | 250 | slot.json = null; |
| 245 | 251 | slot.failed = false; |
| 252 | slot.fail_code = bridge_error.BridgeError.NativeCallFailed; | |
| 246 | 253 | slot.in_use = false; |
| 247 | 254 | slot.generation +%= 1; |
| 248 | 255 | } |
| @@ -256,11 +263,7 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 256 | 263 | defer request_context.pop(); |
| 257 | 264 | |
| 258 | 265 | if (failed) { |
| 259 | bridge_error.sendErrorToJS( | |
| 260 | std.heap.c_allocator, | |
| 261 | action, | |
| 262 | bridge_error.BridgeError.NativeCallFailed, | |
| 263 | ); | |
| 266 | bridge_error.sendErrorToJS(std.heap.c_allocator, action, fail_code); | |
| 264 | 267 | return; |
| 265 | 268 | } |
| 266 | 269 | |
| @@ -305,6 +308,26 @@ pub fn deliverJson(ticket: Ticket, json: []const u8) void { | ||
| 305 | 308 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain); |
| 306 | 309 | } |
| 307 | 310 | |
| 311 | /// Reply to a captured call with a *specific* error. | |
| 312 | /// | |
| 313 | /// `deliverError` reports `NativeCallFailed`, which is right for "the answer | |
| 314 | /// could not be shaped" and wrong for everything else. A user tapping Cancel | |
| 315 | /// on a picker is not a native call failing — the call worked perfectly and | |
| 316 | /// the answer is "no". Telling the page otherwise sends anyone reading the | |
| 317 | /// error looking for a bug that is not there. | |
| 318 | pub fn deliverErrorCode(ticket: Ticket, err: bridge_error.BridgeError) void { | |
| 319 | slots_mutex.lock(); | |
| 320 | const valid = slots[ticket.index].in_use and slots[ticket.index].generation == ticket.generation; | |
| 321 | if (valid) { | |
| 322 | slots[ticket.index].failed = true; | |
| 323 | slots[ticket.index].fail_code = err; | |
| 324 | } | |
| 325 | slots_mutex.unlock(); | |
| 326 | ||
| 327 | if (!valid) return; | |
| 328 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain); | |
| 329 | } | |
| 330 | ||
| 308 | 331 | /// Reply to a captured call with an error rather than a result. |
| 309 | 332 | /// |
| 310 | 333 | /// A module's own completion can fail to shape its answer — a nil object where |
| @@ -425,3 +448,41 @@ test "a reply that could not be shaped is flagged, not silently downgraded" { | ||
| 425 | 448 | slots[ticket.index].in_use = false; |
| 426 | 449 | slots[ticket.index].generation +%= 1; |
| 427 | 450 | } |
| 451 | ||
| 452 | test "a cancel reports being cancelled, not a native failure" { | |
| 453 | // The distinction matters to whoever reads the error. A user tapping | |
| 454 | // Cancel is not a native call failing — the call worked and the answer is | |
| 455 | // "no" — and reporting NativeCallFailed sends a reader looking for a bug | |
| 456 | // that is not there. | |
| 457 | request_context.push(11); | |
| 458 | const ticket = acquire("pickImage") orelse return error.PoolUnexpectedlyFull; | |
| 459 | request_context.pop(); | |
| 460 | ||
| 461 | deliverErrorCode(ticket, bridge_error.BridgeError.Cancelled); | |
| 462 | ||
| 463 | slots_mutex.lock(); | |
| 464 | defer slots_mutex.unlock(); | |
| 465 | try testing.expect(slots[ticket.index].failed); | |
| 466 | try testing.expectEqual(bridge_error.BridgeError.Cancelled, slots[ticket.index].fail_code); | |
| 467 | ||
| 468 | slots[ticket.index].failed = false; | |
| 469 | slots[ticket.index].fail_code = bridge_error.BridgeError.NativeCallFailed; | |
| 470 | slots[ticket.index].in_use = false; | |
| 471 | slots[ticket.index].generation +%= 1; | |
| 472 | } | |
| 473 | ||
| 474 | test "the generic deliverError still means a native failure" { | |
| 475 | // deliverErrorCode must not have changed what deliverError says: a | |
| 476 | // shaping failure genuinely is a native call that did not produce an | |
| 477 | // answer, and that is the default a slot resets to. | |
| 478 | const ticket = acquire("x") orelse return error.PoolUnexpectedlyFull; | |
| 479 | deliverError(ticket); | |
| 480 | ||
| 481 | slots_mutex.lock(); | |
| 482 | defer slots_mutex.unlock(); | |
| 483 | try testing.expectEqual(bridge_error.BridgeError.NativeCallFailed, slots[ticket.index].fail_code); | |
| 484 | ||
| 485 | slots[ticket.index].failed = false; | |
| 486 | slots[ticket.index].in_use = false; | |
| 487 | slots[ticket.index].generation +%= 1; | |
| 488 | } | |
| @@ -0,0 +1,193 @@ | ||
| 1 | //! Building an Objective-C delegate class at runtime. | |
| 2 | //! | |
| 3 | //! Three files now need one — the script message handler in `ios.zig`, the | |
| 4 | //! `CLLocationManagerDelegate` in `bridge_mobile_location.zig`, and every | |
| 5 | //! presented picker (image, document, contact), each of which answers through | |
| 6 | //! a did-finish/did-cancel pair. The registration dance is the same every | |
| 7 | //! time: `objc_getClass` first so a second call is a no-op, then | |
| 8 | //! `objc_allocateClassPair`, then a `class_addMethod` per selector with its | |
| 9 | //! type encoding, then `objc_registerClassPair`. | |
| 10 | //! | |
| 11 | //! ## What this deliberately does not own | |
| 12 | //! | |
| 13 | //! It does not route callbacks to pending requests. That was tempting and | |
| 14 | //! would have been wrong: a location delegate serves a one-shot *and* a | |
| 15 | //! running stream simultaneously, a picker is modal and serves exactly one | |
| 16 | //! call, and a message handler serves none — it dispatches. Three different | |
| 17 | //! lifetimes, and a factory that guessed at one would push the other two into | |
| 18 | //! working around it. Each module keeps its own state and its own `ios_async` | |
| 19 | //! ticket; this only makes the class exist. | |
| 20 | //! | |
| 21 | //! It also does not retain the delegate *instance*. `instantiate` returns a | |
| 22 | //! +1 object and says so; the caller stores it in a module-level var, because | |
| 23 | //! only the caller knows whether the framework holds the delegate weakly | |
| 24 | //! (`CLLocationManager` and `UIImagePickerController` both do — a released | |
| 25 | //! instance is a crash, not a leak). | |
| 26 | //! | |
| 27 | //! ## Type encodings, which are the part that fails silently | |
| 28 | //! | |
| 29 | //! `class_addMethod`'s encoding string is not checked against the IMP. Get it | |
| 30 | //! wrong and the method still registers, still gets called, and reads its | |
| 31 | //! arguments from the wrong registers. There is no compile error and often no | |
| 32 | //! crash — just wrong values. The `enc` helpers below name the shapes these | |
| 33 | //! delegates actually use so a caller picks one rather than spelling it. | |
| 34 | ||
| 35 | const std = ; | |
| 36 | const builtin = ; | |
| 37 | const objc_runtime = ; | |
| 38 | ||
| 39 | const objc = objc_runtime.objc; | |
| 40 | ||
| 41 | /// Type encodings for the delegate signatures in use. | |
| 42 | /// | |
| 43 | /// Every Objective-C method takes `self` (`@`) and `_cmd` (`:`) first; the | |
| 44 | /// leading character is the return type. Named rather than spelled at call | |
| 45 | /// sites because a typo here is invisible until an argument reads as garbage. | |
| 46 | pub const enc = struct { | |
| 47 | /// `- (void)m:(id)a` — one object argument. | |
| 48 | pub const void_one_object: [:0]const u8 = "v@:@"; | |
| 49 | /// `- (void)m:(id)a :(id)b` — two object arguments. The | |
| 50 | /// did-finish-picking pair and `userContentController:didReceiveScriptMessage:` | |
| 51 | /// are both this shape. | |
| 52 | pub const void_two_objects: [:0]const u8 = "v@:@@"; | |
| 53 | /// `- (BOOL)m:(id)a :(id)b` — `application:didFinishLaunchingWithOptions:`. | |
| 54 | /// | |
| 55 | /// `B` is C99 `_Bool`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is | |
| 56 | /// defined, so `BOOL` is `bool` rather than `signed char`. | |
| 57 | pub const bool_two_objects: [:0]const u8 = "B@:@@"; | |
| 58 | }; | |
| 59 | ||
| 60 | /// One method to add to a class. | |
| 61 | pub const Method = struct { | |
| 62 | /// The selector, e.g. `"imagePickerControllerDidCancel:"`. | |
| 63 | selector: [:0]const u8, | |
| 64 | /// The implementation. Must be an `export fn ... callconv(.c)` whose first | |
| 65 | /// two parameters are `objc.id` (self) and `objc.SEL` (_cmd). | |
| 66 | imp: *const anyopaque, | |
| 67 | /// The type encoding. Use one of `enc`. | |
| 68 | types: [:0]const u8, | |
| 69 | }; | |
| 70 | ||
| 71 | pub const Error = error{ | |
| 72 | UnsupportedPlatform, | |
| 73 | ClassNotFound, | |
| 74 | ClassAllocationFailed, | |
| 75 | MethodNotAdded, | |
| 76 | SelectorNotFound, | |
| 77 | InstantiationFailed, | |
| 78 | }; | |
| 79 | ||
| 80 | /// Register `name` as a subclass of `superclass_name` carrying `methods`. | |
| 81 | /// | |
| 82 | /// Idempotent: a second call with the same name returns the existing class | |
| 83 | /// without touching it. That matters because these registrations happen lazily | |
| 84 | /// on first use, and "first use" can be reached from more than one action. | |
| 85 | /// | |
| 86 | /// A partially-built class is not registered. If any `class_addMethod` fails | |
| 87 | /// the pair is abandoned rather than registered with a missing method — a | |
| 88 | /// class whose delegate method is absent silently does nothing when the | |
| 89 | /// framework calls it, which is the failure this whole migration keeps | |
| 90 | /// finding. | |
| 91 | pub fn defineClass( | |
| 92 | name: [:0]const u8, | |
| 93 | superclass_name: [:0]const u8, | |
| 94 | methods: []const Method, | |
| 95 | ) Error!objc.Class { | |
| 96 | if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform; | |
| 97 | ||
| 98 | if (objc.objc_getClass(name.ptr)) |existing| return existing; | |
| 99 | ||
| 100 | const superclass = objc.objc_getClass(superclass_name.ptr) orelse return Error.ClassNotFound; | |
| 101 | const cls = objc.objc_allocateClassPair(superclass, name.ptr, 0) orelse | |
| 102 | return Error.ClassAllocationFailed; | |
| 103 | ||
| 104 | for (methods) |method| { | |
| 105 | const sel = objc.sel_registerName(method.selector.ptr) orelse return Error.SelectorNotFound; | |
| 106 | const imp: objc.IMP = @ptrCast(@constCast(method.imp)); | |
| 107 | if (!objc.class_addMethod(cls, sel, imp, method.types.ptr)) { | |
| 108 | // Deliberately not registered. An unregistered class pair is | |
| 109 | // leaked, which is one allocation for the life of the process and | |
| 110 | // strictly better than a registered class that answers a delegate | |
| 111 | // callback by doing nothing. | |
| 112 | return Error.MethodNotAdded; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | objc.objc_registerClassPair(cls); | |
| 117 | return cls; | |
| 118 | } | |
| 119 | ||
| 120 | /// `[[cls alloc] init]`, +1. | |
| 121 | /// | |
| 122 | /// The caller owns the reference and must keep it alive for as long as the | |
| 123 | /// framework might call back. Every delegate protocol here is held weakly by | |
| 124 | /// its owner, so storing this in a module-level var is not an optimisation — | |
| 125 | /// dropping it is a use-after-free the first time the user taps Cancel. | |
| 126 | pub fn instantiate(cls: objc.Class) Error!objc.id { | |
| 127 | if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform; | |
| 128 | return objc.allocInit(cls) catch Error.InstantiationFailed; | |
| 129 | } | |
| 130 | ||
| 131 | const testing = std.testing; | |
| 132 | ||
| 133 | test "a class is registered once and re-registration returns the same class" { | |
| 134 | if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 135 | ||
| 136 | const first = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{}); | |
| 137 | const second = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{}); | |
| 138 | ||
| 139 | // Same pointer, not merely both non-null: a second allocateClassPair for a | |
| 140 | // taken name returns null, so a factory that did not check first would | |
| 141 | // fail on the second call rather than returning what exists. | |
| 142 | try testing.expectEqual(first, second); | |
| 143 | try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe") != null); | |
| 144 | } | |
| 145 | ||
| 146 | test "a method added by the factory is actually reachable by selector" { | |
| 147 | if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 148 | ||
| 149 | const S = struct { | |
| 150 | var called: bool = false; | |
| 151 | export fn craftDelegateFactoryProbeMethod(_: objc.id, _: objc.SEL, _: objc.id) void { | |
| 152 | called = true; | |
| 153 | } | |
| 154 | }; | |
| 155 | ||
| 156 | const cls = try defineClass("CraftDelegateFactoryProbe2", "NSObject", &.{ | |
| 157 | .{ | |
| 158 | .selector = "craftProbe:", | |
| 159 | .imp = @ptrCast(&S.craftDelegateFactoryProbeMethod), | |
| 160 | .types = enc.void_one_object, | |
| 161 | }, | |
| 162 | }); | |
| 163 | ||
| 164 | const instance = try instantiate(cls); | |
| 165 | const sel = objc.sel_registerName("craftProbe:") orelse return error.SelectorNotFound; | |
| 166 | ||
| 167 | // The point of the test: registration is not enough, the runtime has to | |
| 168 | // dispatch to it. A wrong encoding still registers and still dispatches, | |
| 169 | // so this proves reachability rather than correctness of the encoding — | |
| 170 | // which nothing can check but a real call with real arguments. | |
| 171 | objc.msgSendVoid1(instance, sel, @as(objc.id, null)); | |
| 172 | try testing.expect(S.called); | |
| 173 | } | |
| 174 | ||
| 175 | test "an unknown superclass is refused rather than silently subclassing NSObject" { | |
| 176 | if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 177 | ||
| 178 | try testing.expectError( | |
| 179 | Error.ClassNotFound, | |
| 180 | defineClass("CraftDelegateFactoryProbe3", "NoSuchSuperclassExists", &.{}), | |
| 181 | ); | |
| 182 | // And nothing was left half-built under that name. | |
| 183 | try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe3") == null); | |
| 184 | } | |
| 185 | ||
| 186 | test "the encodings name the shapes these delegates actually use" { | |
| 187 | // A typo in an encoding registers fine and reads arguments from the wrong | |
| 188 | // registers — no compile error, often no crash, just wrong values. Naming | |
| 189 | // them is the only defence available, so the names are pinned. | |
| 190 | try testing.expectEqualStrings("v@:@", enc.void_one_object); | |
| 191 | try testing.expectEqualStrings("v@:@@", enc.void_two_objects); | |
| 192 | try testing.expectEqualStrings("B@:@@", enc.bool_two_objects); | |
| 193 | } | |
| @@ -5,6 +5,7 @@ const request_context = ; | ||
| 5 | 5 | const bridge_error = ; |
| 6 | 6 | pub const ios_async = ; |
| 7 | 7 | pub const ios_events = ; |
| 8 | pub const ios_delegate = ; | |
| 8 | 9 | const bridge_mobile = ; |
| 9 | 10 | const bridge_mobile_clipboard = ; |
| 10 | 11 | const bridge_mobile_haptics = ; |
| @@ -25,6 +26,9 @@ const bridge_mobile_watch = ; | ||
| 25 | 26 | const bridge_mobile_location = ; |
| 26 | 27 | const bridge_mobile_locrecording = ; |
| 27 | 28 | const bridge_mobile_motion = ; |
| 29 | const bridge_mobile_imagepicker = ; | |
| 30 | const bridge_mobile_filepicker = ; | |
| 31 | const bridge_mobile_contactpicker = ; | |
| 28 | 32 | |
| 29 | 33 | const objc = objc_runtime.objc; |
| 30 | 34 | |
| @@ -232,6 +236,9 @@ const mobile_bridges = .{ | ||
| 232 | 236 | bridge_mobile_location.LocationBridge, |
| 233 | 237 | bridge_mobile_locrecording.LocationRecordingBridge, |
| 234 | 238 | bridge_mobile_motion.MotionBridge, |
| 239 | bridge_mobile_imagepicker.ImagePickerBridge, | |
| 240 | bridge_mobile_filepicker.FilePickerBridge, | |
| 241 | bridge_mobile_contactpicker.ContactPickerBridge, | |
| 235 | 242 | }; |
| 236 | 243 | |
| 237 | 244 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| @@ -388,6 +395,7 @@ fn buildShimError( | ||
| 388 | 395 | test { |
| 389 | 396 | _ = ios_async; |
| 390 | 397 | _ = ios_events; |
| 398 | _ = ios_delegate; | |
| 391 | 399 | } |
| 392 | 400 | |
| 393 | 401 | const testing = std.testing; |
| @@ -44,6 +44,9 @@ const zig_sources = [_][]const u8{ | ||
| 44 | 44 | @embedFile("src/bridge_mobile_location.zig"), |
| 45 | 45 | @embedFile("src/bridge_mobile_locrecording.zig"), |
| 46 | 46 | @embedFile("src/bridge_mobile_motion.zig"), |
| 47 | @embedFile("src/bridge_mobile_imagepicker.zig"), | |
| 48 | @embedFile("src/bridge_mobile_filepicker.zig"), | |
| 49 | @embedFile("src/bridge_mobile_contactpicker.zig"), | |
| 47 | 50 | }; |
| 48 | 51 | |
| 49 | 52 | /// The action list lives in the `switch action` block, and nowhere else. |
| @@ -81,7 +84,10 @@ const dispatch_end = "func webView("; | ||
| 81 | 84 | /// would make Zig dispatch and refuse an action the shim serves correctly. |
| 82 | 85 | /// 58 after the event channel landed and unblocked the location tier: the |
| 83 | 86 | /// first actions that push a stream to the page rather than answering a call. |
| 84 | const max_not_yet_migrated: usize = 58; | |
| 87 | /// 53 after the presented pickers — image, document and contact — landed on | |
| 88 | /// the delegate factory, the last of the three mechanisms this migration | |
| 89 | /// needed. Everything remaining is a repetition of a proven pattern. | |
| 90 | const max_not_yet_migrated: usize = 53; | |
| 85 | 91 | |
| 86 | 92 | fn dispatcherRegion() []const u8 { |
| 87 | 93 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |