also looking at this
iOS: biometric prompt and Siri shortcuts — ratchet 43 → 40
#94
9 files
+1090
-3
| @@ -63,7 +63,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \ | ||
| 63 | 63 | "$OUT/main.o" "$OUT/page.o" \ |
| 64 | 64 | "$ROOT/packages/zig/zig-out/lib/$LIB" \ |
| 65 | 65 | -framework UIKit -framework WebKit -framework Foundation -framework Security \ |
| 66 | -framework Vision \ | |
| 66 | -framework Vision -framework LocalAuthentication \ | |
| 67 | 67 | -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \ |
| 68 | 68 | -o "$APP/CraftSlice" |
| 69 | 69 | |
| @@ -100,6 +100,7 @@ LAUNCH_PID=$! | ||
| 100 | 100 | for _ in $(seq 1 60); do |
| 101 | 101 | if grep -q 'i=26' "$LOG" 2>/dev/null && grep -q 'i=28' "$LOG" 2>/dev/null \ |
| 102 | 102 | && grep -q 'i=32' "$LOG" 2>/dev/null && grep -q 'i=22' "$LOG" 2>/dev/null \ |
| 103 | && grep -q 'i=34' "$LOG" 2>/dev/null && grep -q 'i=38' "$LOG" 2>/dev/null \ | |
| 103 | 104 | && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null; then sleep 1; break; fi |
| 104 | 105 | sleep 1 |
| 105 | 106 | done |
| @@ -267,4 +268,19 @@ C32="$(count 32)" | ||
| 267 | 268 | [ "$C32" -ge 1 ] || { echo "FAIL: detectObjects neither answered nor failed — it hung"; exit 1; } |
| 268 | 269 | echo "ok: detectObjects answered rather than hanging (i=32 seen ${C32}x)" |
| 269 | 270 | |
| 271 | C34="$(count 34)" | |
| 272 | [ "$C34" -ge 1 ] || { echo "FAIL: authenticate did not refuse with a specific code"; exit 1; } | |
| 273 | echo "ok: biometrics refused by the framework, not by a missing class (i=34 seen ${C34}x)" | |
| 274 | ||
| 275 | C36="$(count 36)" | |
| 276 | [ "$C36" -ge 1 ] || { echo "FAIL: registerSiriShortcut did not echo the donated shortcut"; exit 1; } | |
| 277 | echo "ok: NSUserActivity donated and echoed back (i=36 seen ${C36}x)" | |
| 278 | ||
| 279 | C38="$(count 38)" | |
| 280 | # removeSiriShortcut answers from a void(^)(void) completion that carries no | |
| 281 | # arguments at all, so this reply arriving proves Foundation fired the block | |
| 282 | # and the parked payload was delivered under the right request id. | |
| 283 | [ "$C38" -ge 1 ] || { echo "FAIL: the deletion completion never fired, or its reply was lost"; exit 1; } | |
| 284 | echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)" | |
| 285 | ||
| 270 | 286 | echo "PASS" |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | "enableHaptics": false, |
| 8 | 8 | "enableShare": false, |
| 9 | 9 | "enableCamera": true, |
| 10 | "enableBiometric": false, | |
| 10 | "enableBiometric": true, | |
| 11 | 11 | "enablePushNotifications": false, |
| 12 | 12 | "enableSecureStorage": true, |
| 13 | 13 | "enableGeolocation": false, |
| @@ -48,6 +48,7 @@ const char craft_slice_page[] = | ||
| 48 | 48 | "var sentErrorAck = false, sentCode = false, sentDisabled = false;\n" |
| 49 | 49 | "var sentShot = false, sentDeepLink = false;\n" |
| 50 | 50 | "var sentOcr = false, sentClassify = false, sentDetect = false;\n" |
| 51 | "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n" | |
| 51 | 52 | "window.__craftBridgeError = function (ctx) {\n" |
| 52 | 53 | " // The message crossed Zig's escaping with a backslash, quote, and\n" |
| 53 | 54 | " // newline intact; anything mangled and this equality fails, i=7 never\n" |
| @@ -67,6 +68,15 @@ const char craft_slice_page[] = | ||
| 67 | 68 | " // that they ANSWER: before this phase both were gated in Swift with no\n" |
| 68 | 69 | " // else, so the page's promise hung. Either a real result or a named\n" |
| 69 | 70 | " // failure settles it, and both are asserted the same way.\n" |
| 71 | " // LocalAuthentication is linked, so this is the framework's own answer\n" | |
| 72 | " // and not a missing-class refusal: PERMISSION_DENIED means\n" | |
| 73 | " // canEvaluatePolicy said no. Before this phase the action was gated in\n" | |
| 74 | " // Swift with no else and the promise never settled at all.\n" | |
| 75 | " if (!sentAuth && ctx && ctx.error === true && ctx.id === 95\n" | |
| 76 | " && ctx.code === 'PERMISSION_DENIED') {\n" | |
| 77 | " sentAuth = true;\n" | |
| 78 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:34});\n" | |
| 79 | " }\n" | |
| 70 | 80 | " if (!sentClassify && ctx && ctx.error === true && ctx.id === 93) {\n" |
| 71 | 81 | " sentClassify = true;\n" |
| 72 | 82 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:28});\n" |
| @@ -149,6 +159,25 @@ const char craft_slice_page[] = | ||
| 149 | 159 | " // 'CRAFT' from an image it never decoded, and the reply had to survive\n" |
| 150 | 160 | " // base64 -> NSData -> UIImage -> CGImage -> VNRecognizeTextRequest and\n" |
| 151 | 161 | " // come back over the background-queue hop with its ticket intact.\n" |
| 162 | " // The reply echoes both fields the page sent, so it proves the payload\n" | |
| 163 | " // was parsed and routed — and the donation ran without throwing.\n" | |
| 164 | " if (!sentSiriAdd && action === 'registerSiriShortcut' && payload\n" | |
| 165 | " && payload.registered === true && payload.action === 'sliceAct'\n" | |
| 166 | " && payload.phrase === 'open the slice') {\n" | |
| 167 | " sentSiriAdd = true;\n" | |
| 168 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:36});\n" | |
| 169 | " bridge.postMessage({t:'mobile', a:'removeSiriShortcut',\n" | |
| 170 | " d: JSON.stringify({action:'sliceAct'}), i:97});\n" | |
| 171 | " }\n" | |
| 172 | " // removeSiriShortcut answers from inside Foundation's completion\n" | |
| 173 | " // handler, a void(^)(void) that carries nothing at all — so this reply\n" | |
| 174 | " // arriving proves the block fired and its parked payload was delivered\n" | |
| 175 | " // under the request id captured back at dispatch.\n" | |
| 176 | " if (!sentSiriDel && action === 'removeSiriShortcut' && payload\n" | |
| 177 | " && payload.removed === true && payload.action === 'sliceAct') {\n" | |
| 178 | " sentSiriDel = true;\n" | |
| 179 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:38});\n" | |
| 180 | " }\n" | |
| 152 | 181 | " if (!sentOcr && action === 'recognizeText' && Array.isArray(payload)) {\n" |
| 153 | 182 | " var joined = payload.map(function (r) { return r.text; }).join(' ').toUpperCase();\n" |
| 154 | 183 | " var box = payload.length ? payload[0].boundingBox : null;\n" |
| @@ -227,6 +256,13 @@ const char craft_slice_page[] = | ||
| 227 | 256 | " bridge.postMessage({t:'mobile', a:'recognizeText', d: JSON.stringify({image:b64}), i:92});\n" |
| 228 | 257 | " bridge.postMessage({t:'mobile', a:'classifyImage', d: JSON.stringify({image:b64}), i:93});\n" |
| 229 | 258 | " bridge.postMessage({t:'mobile', a:'detectObjects', d: JSON.stringify({image:b64}), i:94});\n" |
| 259 | " // A simulator has no enrolled biometrics, so canEvaluatePolicy refuses\n" | |
| 260 | " // before any prompt is presented. What is asserted is that the refusal\n" | |
| 261 | " // is specific rather than the generic native failure.\n" | |
| 262 | " bridge.postMessage({t:'mobile', a:'authenticate', d: JSON.stringify({reason:'slice'}), i:95});\n" | |
| 263 | " // NSUserActivity donation: no Intents, no permission, no UI.\n" | |
| 264 | " bridge.postMessage({t:'mobile', a:'registerSiriShortcut',\n" | |
| 265 | " d: JSON.stringify({action:'sliceAct', phrase:'open the slice'}), i:96});\n" | |
| 230 | 266 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 231 | 267 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 232 | 268 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| @@ -404,6 +404,12 @@ pub fn build(b: *std.Build) void { | ||
| 404 | 404 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{ |
| 405 | 405 | .root_source_file = b.path("src/bridge_mobile_vision.zig"), |
| 406 | 406 | }); |
| 407 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_auth.zig", .{ | |
| 408 | .root_source_file = b.path("src/bridge_mobile_auth.zig"), | |
| 409 | }); | |
| 410 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_siri.zig", .{ | |
| 411 | .root_source_file = b.path("src/bridge_mobile_siri.zig"), | |
| 412 | }); | |
| 407 | 413 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{ |
| 408 | 414 | .root_source_file = b.path("src/bridge_mobile.zig"), |
| 409 | 415 | }); |
| @@ -0,0 +1,466 @@ | ||
| 1 | //! The `mobile` namespace's biometric prompt: `authenticate`. | |
| 2 | //! | |
| 3 | //! Separate from `bridge_mobile_biometric.zig` on purpose. That file serves | |
| 4 | //! the three `*BiometricPersistence` actions and says at the top that it | |
| 5 | //! contains no `LAContext` — because Swift implements those three against one | |
| 6 | //! in-memory `Date` and never consults biometrics for them. It is the rare | |
| 7 | //! mobile module that is pure Zig with no Darwin gate anywhere, and folding a | |
| 8 | //! LocalAuthentication call into it would make it Objective-C-bound on every | |
| 9 | //! platform the dispatch chain compiles for, to no one's benefit. The prompt | |
| 10 | //! lives here; the bookkeeping stays there. | |
| 11 | //! | |
| 12 | //! Unblocked by `ios_config.zig`: the Swift arm is behind `config.enableBiometric` | |
| 13 | //! with no `else`, so a page calling `authenticate` in an app that left the | |
| 14 | //! flag off got a promise that never settled. `ios_dispatch.route` refuses on | |
| 15 | //! the flag now, and this is what happens once the flag is on. | |
| 16 | //! | |
| 17 | //! ## Two replies, and neither is the pooled one | |
| 18 | //! | |
| 19 | //! Swift resolves the bare JSON `true` on success and *rejects* on failure. | |
| 20 | //! `ios_async`'s pre-built `boolErrorBlock` cannot express that: its delivery | |
| 21 | //! path answers the strings `"granted"` and `"denied"`, which is the | |
| 22 | //! permission vocabulary that `checkPermission` speaks and not this. A page | |
| 23 | //! doing `if (await craft.authenticate())` would read `"denied"` as truthy and | |
| 24 | //! let the user straight in. | |
| 25 | //! | |
| 26 | //! So the block below is the module's own, in the shape | |
| 27 | //! `bridge_mobile_notifications.zig` established: global, one comptime | |
| 28 | //! invoke per slot so it knows its ticket without capturing, and answering | |
| 29 | //! through `ios_async.deliverJson` / `deliverErrorCode`. | |
| 30 | //! | |
| 31 | //! ## Cancelled is not denied | |
| 32 | //! | |
| 33 | //! Swift rejects every failure with the same `localizedDescription`, so a page | |
| 34 | //! cannot tell "the user pressed Cancel" from "there are no biometrics on this | |
| 35 | //! device". The protocol has codes for both, and the distinction is the one a | |
| 36 | //! caller actually branches on: a cancel is a decision to re-offer the prompt, | |
| 37 | //! everything else is a reason to fall back to a passcode. `LAErrorUserCancel` | |
| 38 | //! becomes `CANCELLED`; the rest stay `PERMISSION_DENIED`. This is the same | |
| 39 | //! divergence `bridge_mobile_filepicker.zig` already makes for a dismissed | |
| 40 | //! picker, for the same reason. | |
| 41 | //! | |
| 42 | //! ## The context's lifetime | |
| 43 | //! | |
| 44 | //! `LAContext` is created +1 and released in the reply block, which is the | |
| 45 | //! point Swift's local goes out of scope. Releasing it earlier would free it | |
| 46 | //! while LocalAuthentication is still showing the prompt; never releasing it | |
| 47 | //! would leak one context per call. The one path that leaks is an app killed | |
| 48 | //! mid-prompt, where the process is going away anyway. | |
| 49 | ||
| 50 | const std = ; | |
| 51 | const builtin = ; | |
| 52 | const capabilities = ; | |
| 53 | const bridge_error = ; | |
| 54 | const objc_runtime = ; | |
| 55 | const ios_async = ; | |
| 56 | const compat_mutex = ; | |
| 57 | ||
| 58 | const objc = objc_runtime.objc; | |
| 59 | const BridgeError = bridge_error.BridgeError; | |
| 60 | const Id = ?*anyopaque; | |
| 61 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 62 | ||
| 63 | pub const A = struct { | |
| 64 | pub const authenticate = "authenticate"; | |
| 65 | }; | |
| 66 | ||
| 67 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 68 | .{ .name = A.authenticate, .reply = .result }, | |
| 69 | }; | |
| 70 | ||
| 71 | /// `LAPolicyDeviceOwnerAuthenticationWithBiometrics`. | |
| 72 | /// | |
| 73 | /// Swift passes `.deviceOwnerAuthenticationWithBiometrics`, which is 1. The | |
| 74 | /// neighbouring `LAPolicyDeviceOwnerAuthentication` is 2 and falls back to the | |
| 75 | /// device passcode — a materially weaker check that would let a page believe a | |
| 76 | /// fingerprint was presented when a four-digit code was typed. | |
| 77 | const policy_biometrics: c_long = 1; | |
| 78 | ||
| 79 | /// `LAErrorUserCancel`. The user pressed Cancel on the prompt. | |
| 80 | const la_error_user_cancel: c_long = -2; | |
| 81 | ||
| 82 | /// Swift's `body["reason"] as? String ?? "Authenticate to continue"`. | |
| 83 | /// | |
| 84 | /// The string is shown to the user inside the system prompt, so it is part of | |
| 85 | /// the app's visible text and not a detail. An absent reason defaults; a | |
| 86 | /// present non-string is refused rather than replaced, which is where this | |
| 87 | /// diverges from `as?` silently substituting the default. | |
| 88 | const default_reason = "Authenticate to continue"; | |
| 89 | ||
| 90 | /// The reply Swift resolves on success: the bare JSON `true`, not an object. | |
| 91 | /// | |
| 92 | /// `resolveCallback(callbackId, result: true)` under `.fragmentsAllowed`. | |
| 93 | /// `craft-bridge.js` resolves with `payload || {}`, so an object here would | |
| 94 | /// still be truthy and a page comparing `=== true` would silently stop | |
| 95 | /// working. | |
| 96 | const success_reply = "true"; | |
| 97 | ||
| 98 | pub const AuthBridge = struct { | |
| 99 | allocator: std.mem.Allocator, | |
| 100 | ||
| 101 | const Self = @This(); | |
| 102 | ||
| 103 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 104 | return .{ .allocator = allocator }; | |
| 105 | } | |
| 106 | ||
| 107 | pub fn deinit(_: *Self) void {} | |
| 108 | ||
| 109 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 110 | if (!std.mem.eql(u8, action, A.authenticate)) return BridgeError.UnknownAction; | |
| 111 | try self.authenticate(data); | |
| 112 | } | |
| 113 | ||
| 114 | fn authenticate(self: *Self, data: []const u8) !void { | |
| 115 | if (!is_darwin) return BridgeError.PlatformNotSupported; | |
| 116 | ||
| 117 | const reason = try readReason(self.allocator, data); | |
| 118 | defer self.allocator.free(reason); | |
| 119 | ||
| 120 | const LAContext = objc.objc_getClass("LAContext") orelse { | |
| 121 | std.log.warn( | |
| 122 | "authenticate: LAContext is not in this process; the app does not link " ++ | |
| 123 | "LocalAuthentication", | |
| 124 | .{}, | |
| 125 | ); | |
| 126 | return BridgeError.PlatformNotSupported; | |
| 127 | }; | |
| 128 | const context = objc.allocInit(LAContext) catch return BridgeError.NativeCallFailed; | |
| 129 | errdefer release(context); | |
| 130 | ||
| 131 | // Swift's guard. Asking first is not politeness: `evaluatePolicy` on a | |
| 132 | // device with no enrolled biometrics still calls back, but only after | |
| 133 | // presenting and dismissing UI, and the reason string would flash on | |
| 134 | // screen for a check that could never succeed. | |
| 135 | try requireBiometricsAvailable(context); | |
| 136 | ||
| 137 | const ns_reason = objc.createNSString(reason, self.allocator) catch | |
| 138 | return BridgeError.AllocationFailed; | |
| 139 | ||
| 140 | const ticket = ios_async.acquire(A.authenticate) orelse { | |
| 141 | std.log.warn( | |
| 142 | "authenticate: no free reply slot; {d} native calls are already awaiting one", | |
| 143 | .{ios_async.max_in_flight}, | |
| 144 | ); | |
| 145 | return BridgeError.NativeCallFailed; | |
| 146 | }; | |
| 147 | errdefer ios_async.abandon(ticket); | |
| 148 | ||
| 149 | publishPendingCall(ticket, context); | |
| 150 | ||
| 151 | const sel = objc.sel_registerName("evaluatePolicy:localizedReason:reply:") orelse | |
| 152 | return BridgeError.NativeCallFailed; | |
| 153 | const EvaluateFn = *const fn (Id, objc.SEL, c_long, Id, *anyopaque) callconv(.c) void; | |
| 154 | const evaluate: EvaluateFn = @ptrCast(&objc.objc_msgSend); | |
| 155 | evaluate(context, sel, policy_biometrics, ns_reason, replyBlock(ticket)); | |
| 156 | } | |
| 157 | }; | |
| 158 | ||
| 159 | /// `canEvaluatePolicy:error:`, with the framework's own reason in the log. | |
| 160 | /// | |
| 161 | /// Swift rejects with `error?.localizedDescription ?? "Biometric not | |
| 162 | /// available"`. The protocol has no message field, so the description goes to | |
| 163 | /// the log — where "no identities are enrolled" and "biometry is locked out" | |
| 164 | /// are different enough to act on, and both would otherwise arrive as the same | |
| 165 | /// bare code. | |
| 166 | fn requireBiometricsAvailable(context: Id) !void { | |
| 167 | const sel = objc.sel_registerName("canEvaluatePolicy:error:") orelse | |
| 168 | return BridgeError.NativeCallFailed; | |
| 169 | const CanEvaluateFn = *const fn (Id, objc.SEL, c_long, *Id) callconv(.c) bool; | |
| 170 | const canEvaluate: CanEvaluateFn = @ptrCast(&objc.objc_msgSend); | |
| 171 | ||
| 172 | var err: Id = null; | |
| 173 | if (canEvaluate(context, sel, policy_biometrics, &err)) return; | |
| 174 | ||
| 175 | logNSError("authenticate", err); | |
| 176 | return BridgeError.PermissionDenied; | |
| 177 | } | |
| 178 | ||
| 179 | /// The `reason`, or the reason it cannot be used. | |
| 180 | /// | |
| 181 | /// Pure, so the host tests pin every outcome Swift's `as?` collapsed into a | |
| 182 | /// silent default. | |
| 183 | fn readReason(allocator: std.mem.Allocator, data: []const u8) ![]u8 { | |
| 184 | var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) { | |
| 185 | error.OutOfMemory => return err, | |
| 186 | else => return BridgeError.InvalidJSON, | |
| 187 | }; | |
| 188 | defer parsed.deinit(); | |
| 189 | ||
| 190 | const object = switch (parsed.value) { | |
| 191 | .object => |o| o, | |
| 192 | else => return BridgeError.InvalidJSON, | |
| 193 | }; | |
| 194 | ||
| 195 | const value = object.get("reason") orelse | |
| 196 | return allocator.dupe(u8, default_reason) catch BridgeError.AllocationFailed; | |
| 197 | ||
| 198 | return switch (value) { | |
| 199 | // An explicit JSON null is Swift's `NSNull`, which `as? String` turns | |
| 200 | // into nil and the `??` then defaults — so null defaults too. | |
| 201 | .null => allocator.dupe(u8, default_reason) catch BridgeError.AllocationFailed, | |
| 202 | .string => |s| if (s.len == 0) | |
| 203 | BridgeError.InvalidParameter | |
| 204 | else | |
| 205 | allocator.dupe(u8, s) catch BridgeError.AllocationFailed, | |
| 206 | else => BridgeError.InvalidParameter, | |
| 207 | }; | |
| 208 | } | |
| 209 | ||
| 210 | // --------------------------------------------------------------------------- | |
| 211 | // The reply block | |
| 212 | // --------------------------------------------------------------------------- | |
| 213 | ||
| 214 | /// What a slot's reply block needs that the block itself cannot carry. | |
| 215 | /// | |
| 216 | /// The ticket is stored rather than rebuilt from the index: a `Ticket` is an | |
| 217 | /// index *and* a generation, and inventing the generation at reply time would | |
| 218 | /// defeat the check that stops a late completion from answering whoever holds | |
| 219 | /// the slot now. | |
| 220 | const PendingCall = struct { | |
| 221 | ticket: ios_async.Ticket, | |
| 222 | context: Id, | |
| 223 | }; | |
| 224 | ||
| 225 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); | |
| 226 | var pending_mutex: compat_mutex.Mutex = .{}; | |
| 227 | ||
| 228 | /// Record the call a slot's block will answer. The slot is leased exclusively | |
| 229 | /// by this ticket, so the entry is ours to overwrite. | |
| 230 | fn publishPendingCall(ticket: ios_async.Ticket, context: Id) void { | |
| 231 | pending_mutex.lock(); | |
| 232 | defer pending_mutex.unlock(); | |
| 233 | pending_calls[ticket.index] = .{ .ticket = ticket, .context = context }; | |
| 234 | } | |
| 235 | ||
| 236 | /// Read and clear a slot's entry. Clearing is what makes a second fire of the | |
| 237 | /// same completion a no-op rather than a second reply — and, here, a second | |
| 238 | /// `release` of a context that is already gone. | |
| 239 | fn takePendingCall(index: u5) ?PendingCall { | |
| 240 | pending_mutex.lock(); | |
| 241 | defer pending_mutex.unlock(); | |
| 242 | const call = pending_calls[index]; | |
| 243 | pending_calls[index] = null; | |
| 244 | return call; | |
| 245 | } | |
| 246 | ||
| 247 | const BlockDescriptor = extern struct { | |
| 248 | reserved: c_ulong = 0, | |
| 249 | size: c_ulong, | |
| 250 | }; | |
| 251 | ||
| 252 | /// `void (^)(BOOL, NSError *)`. | |
| 253 | /// | |
| 254 | /// The same shape as `ios_async.boolErrorBlock`, and deliberately not that | |
| 255 | /// block: this one answers `true` or an error, where the pooled one answers | |
| 256 | /// `"granted"` or `"denied"`. | |
| 257 | const ReplyBlock = extern struct { | |
| 258 | isa: ?*anyopaque, | |
| 259 | flags: c_int, | |
| 260 | reserved: c_int = 0, | |
| 261 | invoke: *const anyopaque, | |
| 262 | descriptor: *const BlockDescriptor, | |
| 263 | }; | |
| 264 | ||
| 265 | /// 1 << 28. A global block is never copied, so it can be handed to an API that | |
| 266 | /// escapes it with no heap copy and no descriptor lifetime. | |
| 267 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 268 | ||
| 269 | const reply_block_descriptor = BlockDescriptor{ .size = @sizeOf(ReplyBlock) }; | |
| 270 | ||
| 271 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 272 | ||
| 273 | fn makeReplyInvoke(comptime index: u5) *const anyopaque { | |
| 274 | const S = struct { | |
| 275 | fn invoke(_: *const ReplyBlock, success: bool, err: Id) callconv(.c) void { | |
| 276 | replyFired(index, success, err); | |
| 277 | } | |
| 278 | }; | |
| 279 | return @ptrCast(&S.invoke); | |
| 280 | } | |
| 281 | ||
| 282 | fn makeReplyBlocks() [ios_async.max_in_flight]ReplyBlock { | |
| 283 | var out: [ios_async.max_in_flight]ReplyBlock = undefined; | |
| 284 | for (&out, 0..) |*b, i| { | |
| 285 | b.* = .{ | |
| 286 | .isa = &_NSConcreteGlobalBlock, | |
| 287 | .flags = BLOCK_IS_GLOBAL, | |
| 288 | .invoke = makeReplyInvoke(@intCast(i)), | |
| 289 | .descriptor = &reply_block_descriptor, | |
| 290 | }; | |
| 291 | } | |
| 292 | return out; | |
| 293 | } | |
| 294 | ||
| 295 | var reply_blocks: [ios_async.max_in_flight]ReplyBlock = | |
| 296 | if (is_darwin) makeReplyBlocks() else undefined; | |
| 297 | ||
| 298 | fn replyBlock(ticket: ios_async.Ticket) *anyopaque { | |
| 299 | return @ptrCast(&reply_blocks[ticket.index]); | |
| 300 | } | |
| 301 | ||
| 302 | /// Runs on whatever queue LocalAuthentication chose. | |
| 303 | /// | |
| 304 | /// It must not reply from here — `evaluateJavaScript:` is main-thread-only — | |
| 305 | /// so both outcomes go through `ios_async`, which hops to the main queue and | |
| 306 | /// answers under the request id captured back at dispatch. Swift's own | |
| 307 | /// `DispatchQueue.main.async` in this position is the same hop, done by hand. | |
| 308 | fn replyFired(index: u5, success: bool, err: Id) void { | |
| 309 | if (!is_darwin) return; | |
| 310 | ||
| 311 | const call = takePendingCall(index) orelse { | |
| 312 | std.log.warn( | |
| 313 | "authenticate reply fired for slot {d} with no call recorded; ignored", | |
| 314 | .{index}, | |
| 315 | ); | |
| 316 | return; | |
| 317 | }; | |
| 318 | ||
| 319 | // Released here, which is where Swift's local goes out of scope: the | |
| 320 | // prompt is finished with it, and holding it longer would leak one context | |
| 321 | // per authentication. | |
| 322 | release(call.context); | |
| 323 | ||
| 324 | if (success) { | |
| 325 | ios_async.deliverJson(call.ticket, success_reply); | |
| 326 | return; | |
| 327 | } | |
| 328 | ||
| 329 | logNSError(A.authenticate, err); | |
| 330 | ios_async.deliverErrorCode(call.ticket, if (errorCode(err) == la_error_user_cancel) | |
| 331 | BridgeError.Cancelled | |
| 332 | else | |
| 333 | BridgeError.PermissionDenied); | |
| 334 | } | |
| 335 | ||
| 336 | fn errorCode(err: Id) c_long { | |
| 337 | const ns_error = err orelse return 0; | |
| 338 | const sel = objc.sel_registerName("code") orelse return 0; | |
| 339 | const CodeFn = *const fn (Id, objc.SEL) callconv(.c) c_long; | |
| 340 | const codeFn: CodeFn = @ptrCast(&objc.objc_msgSend); | |
| 341 | return codeFn(ns_error, sel); | |
| 342 | } | |
| 343 | ||
| 344 | fn logNSError(action: []const u8, err: Id) void { | |
| 345 | const ns_error = err orelse { | |
| 346 | std.log.warn("{s}: refused, and the framework reported no reason", .{action}); | |
| 347 | return; | |
| 348 | }; | |
| 349 | const sel = objc.sel_registerName("localizedDescription") orelse return; | |
| 350 | const ns_description = objc.msgSendId(ns_error, sel) orelse return; | |
| 351 | const utf8 = objc.getNSStringUTF8(ns_description) orelse return; | |
| 352 | std.log.warn("{s}: {s}", .{ action, std.mem.span(utf8) }); | |
| 353 | } | |
| 354 | ||
| 355 | fn release(object: Id) void { | |
| 356 | const target = object orelse return; | |
| 357 | const sel = objc.sel_registerName("release") orelse return; | |
| 358 | objc.msgSend(target, sel); | |
| 359 | } | |
| 360 | ||
| 361 | const testing = std.testing; | |
| 362 | ||
| 363 | test "the action name matches the Swift case label exactly" { | |
| 364 | try testing.expectEqualStrings("authenticate", A.authenticate); | |
| 365 | } | |
| 366 | ||
| 367 | test "success resolves the bare JSON true, not an object" { | |
| 368 | // Swift's `resolveCallback(callbackId, result: true)` under | |
| 369 | // `.fragmentsAllowed`. An object would still be truthy through | |
| 370 | // `craft-bridge.js`'s `payload || {}`, so a page comparing `=== true` | |
| 371 | // would silently stop working rather than fail visibly. | |
| 372 | try testing.expectEqualStrings("true", success_reply); | |
| 373 | } | |
| 374 | ||
| 375 | test "the policy is the biometric one, not the passcode fallback" { | |
| 376 | // LAPolicyDeviceOwnerAuthentication (2) falls back to the device passcode. | |
| 377 | // Passing it would let a page believe a fingerprint was presented when a | |
| 378 | // four-digit code was typed — the same call, a materially weaker claim. | |
| 379 | try testing.expectEqual(@as(c_long, 1), policy_biometrics); | |
| 380 | try testing.expectEqual(@as(c_long, -2), la_error_user_cancel); | |
| 381 | } | |
| 382 | ||
| 383 | test "an absent or null reason defaults, a mistyped one is refused" { | |
| 384 | // Swift's `body["reason"] as? String ?? "Authenticate to continue"` also | |
| 385 | // rewrites a present-but-non-string reason into the default and reports | |
| 386 | // success — a payload field the page sent, replaced with a different one. | |
| 387 | // Absence defaults here because that is what the injected JS sends; a | |
| 388 | // wrong type does not. | |
| 389 | const absent = try readReason(testing.allocator, "{}"); | |
| 390 | defer testing.allocator.free(absent); | |
| 391 | try testing.expectEqualStrings(default_reason, absent); | |
| 392 | ||
| 393 | const null_reason = try readReason(testing.allocator, "{\"reason\":null}"); | |
| 394 | defer testing.allocator.free(null_reason); | |
| 395 | try testing.expectEqualStrings(default_reason, null_reason); | |
| 396 | ||
| 397 | const given = try readReason(testing.allocator, "{\"reason\":\"Unlock the vault\"}"); | |
| 398 | defer testing.allocator.free(given); | |
| 399 | try testing.expectEqualStrings("Unlock the vault", given); | |
| 400 | ||
| 401 | try testing.expectError( | |
| 402 | BridgeError.InvalidParameter, | |
| 403 | readReason(testing.allocator, "{\"reason\":7}"), | |
| 404 | ); | |
| 405 | try testing.expectError( | |
| 406 | BridgeError.InvalidParameter, | |
| 407 | readReason(testing.allocator, "{\"reason\":\"\"}"), | |
| 408 | ); | |
| 409 | try testing.expectError(BridgeError.InvalidJSON, readReason(testing.allocator, "[]")); | |
| 410 | } | |
| 411 | ||
| 412 | test "every declared action dispatches to something" { | |
| 413 | var bridge = AuthBridge.init(testing.allocator); | |
| 414 | defer bridge.deinit(); | |
| 415 | ||
| 416 | for (capability_actions) |decl| { | |
| 417 | bridge.handleMessage(decl.name, "{}") catch |err| { | |
| 418 | try testing.expect(err != BridgeError.UnknownAction); | |
| 419 | continue; | |
| 420 | }; | |
| 421 | } | |
| 422 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}")); | |
| 423 | } | |
| 424 | ||
| 425 | test "a refusal leases no reply slot" { | |
| 426 | // A lease that is never released narrows the pool for every later call. | |
| 427 | // The host has no LAContext, so this exercises the earliest refusal path. | |
| 428 | if (!is_darwin) return error.SkipZigTest; | |
| 429 | ||
| 430 | var bridge = AuthBridge.init(testing.allocator); | |
| 431 | defer bridge.deinit(); | |
| 432 | bridge.handleMessage(A.authenticate, "{}") catch {}; | |
| 433 | ||
| 434 | pending_mutex.lock(); | |
| 435 | defer pending_mutex.unlock(); | |
| 436 | for (pending_calls) |entry| try testing.expect(entry == null); | |
| 437 | } | |
| 438 | ||
| 439 | test "each slot's reply block is global and has its own invoke" { | |
| 440 | if (!is_darwin) return error.SkipZigTest; | |
| 441 | ||
| 442 | for (&reply_blocks) |*b| { | |
| 443 | // A global block is never copied, which is what makes it safe to hand | |
| 444 | // to an API that escapes it. | |
| 445 | try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); | |
| 446 | try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); | |
| 447 | try testing.expectEqual(@sizeOf(ReplyBlock), @as(usize, @intCast(b.descriptor.size))); | |
| 448 | } | |
| 449 | // Each slot needs its own invoke, or every reply would answer whichever | |
| 450 | // call the shared invoke happened to name. | |
| 451 | try testing.expect(reply_blocks[0].invoke != reply_blocks[1].invoke); | |
| 452 | } | |
| 453 | ||
| 454 | test "a reply for a slot with no recorded call is ignored" { | |
| 455 | // A completion that fires twice, or after the slot was abandoned, must not | |
| 456 | // release a context that is already gone or answer a call that is not | |
| 457 | // there. | |
| 458 | if (!is_darwin) return error.SkipZigTest; | |
| 459 | ||
| 460 | pending_mutex.lock(); | |
| 461 | for (&pending_calls) |*entry| entry.* = null; | |
| 462 | pending_mutex.unlock(); | |
| 463 | ||
| 464 | replyFired(0, true, null); | |
| 465 | replyFired(0, false, null); | |
| 466 | } | |
packages/zig/src/bridge_mobile_siri.zigadded+556-0
| @@ -0,0 +1,556 @@ | ||
| 1 | //! The `mobile` namespace's Siri shortcut pair: `registerSiriShortcut` and | |
| 2 | //! `removeSiriShortcut`. | |
| 3 | //! | |
| 4 | //! Not the same thing as `bridge_mobile_shortcuts.zig`, which serves | |
| 5 | //! `setShortcuts`/`clearShortcuts` — those are `UIApplication.shortcutItems`, | |
| 6 | //! the long-press menu on the app icon. These two are `NSUserActivity` | |
| 7 | //! donations that Siri and Spotlight index. Two features, one English word, | |
| 8 | //! and the modules are kept apart so neither has to explain the other. | |
| 9 | //! | |
| 10 | //! ## No Intents framework, no permission, no UI | |
| 11 | //! | |
| 12 | //! Worth stating because "Siri shortcut" suggests all three. `CraftApp.swift` | |
| 13 | //! never imports Intents for these: `registerSiriShortcut` builds a plain | |
| 14 | //! `NSUserActivity`, sets six properties and calls `becomeCurrent()`; | |
| 15 | //! `removeSiriShortcut` calls one `NSUserActivity` class method. Both are | |
| 16 | //! Foundation, both are ungated in the dispatcher, and neither prompts. That | |
| 17 | //! makes them the last actions in the migration that need nothing but | |
| 18 | //! `objc_msgSend`. | |
| 19 | //! | |
| 20 | //! ## The activity's lifetime is Swift's, deliberately | |
| 21 | //! | |
| 22 | //! Swift's `activity` is a local. ARC releases it when the function returns, | |
| 23 | //! immediately after `becomeCurrent()`, so whether the donation outlives the | |
| 24 | //! call is the system's business and not the app's. This releases at the same | |
| 25 | //! point for the same reason. Holding it in a module-level var would keep the | |
| 26 | //! donation alive longer than the spec does — a divergence in the direction of | |
| 27 | //! "works better", which is still a divergence in behaviour a page can | |
| 28 | //! observe through Spotlight. | |
| 29 | //! | |
| 30 | //! ## Where the activity type comes from | |
| 31 | //! | |
| 32 | //! Swift interpolates the template placeholder: `".\(action)"`, | |
| 33 | //! substituted at project-generation time. Zig has no template, so it reads | |
| 34 | //! `[[NSBundle mainBundle] bundleIdentifier]`, which is what that placeholder | |
| 35 | //! is generated *from* — `packages/ios/src/index.ts` writes the same string | |
| 36 | //! into `project.yml` as `PRODUCT_BUNDLE_IDENTIFIER` and into the Swift | |
| 37 | //! source. A generated app cannot tell the two apart; a hand-edited one where | |
| 38 | //! they disagree gets the identifier the process actually has, which is the | |
| 39 | //! one Siri will match against. | |
| 40 | //! | |
| 41 | //! ## Key order in the replies | |
| 42 | //! | |
| 43 | //! Swift resolves Swift `Dictionary` literals, and `JSONSerialization` emits | |
| 44 | //! their keys in hash order — which is not the literal's order and is not | |
| 45 | //! stable across launches. So no page can depend on key order, and the fixed | |
| 46 | //! order below is a choice this file is free to make rather than a contract it | |
| 47 | //! is preserving. | |
| 48 | ||
| 49 | const std = ; | |
| 50 | const builtin = ; | |
| 51 | const capabilities = ; | |
| 52 | const bridge_error = ; | |
| 53 | const objc_runtime = ; | |
| 54 | const ios_async = ; | |
| 55 | const compat_mutex = ; | |
| 56 | ||
| 57 | const objc = objc_runtime.objc; | |
| 58 | const BridgeError = bridge_error.BridgeError; | |
| 59 | const Id = ?*anyopaque; | |
| 60 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 61 | ||
| 62 | pub const A = struct { | |
| 63 | pub const register_siri_shortcut = "registerSiriShortcut"; | |
| 64 | pub const remove_siri_shortcut = "removeSiriShortcut"; | |
| 65 | }; | |
| 66 | ||
| 67 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 68 | .{ .name = A.register_siri_shortcut, .reply = .result }, | |
| 69 | .{ .name = A.remove_siri_shortcut, .reply = .result }, | |
| 70 | }; | |
| 71 | ||
| 72 | pub const SiriBridge = struct { | |
| 73 | allocator: std.mem.Allocator, | |
| 74 | ||
| 75 | const Self = @This(); | |
| 76 | ||
| 77 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 78 | return .{ .allocator = allocator }; | |
| 79 | } | |
| 80 | ||
| 81 | pub fn deinit(_: *Self) void {} | |
| 82 | ||
| 83 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 84 | if (std.mem.eql(u8, action, A.register_siri_shortcut)) { | |
| 85 | try self.registerShortcut(data); | |
| 86 | } else if (std.mem.eql(u8, action, A.remove_siri_shortcut)) { | |
| 87 | try self.removeShortcut(data); | |
| 88 | } else { | |
| 89 | return BridgeError.UnknownAction; | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | /// Donate an activity, synchronously. | |
| 94 | /// | |
| 95 | /// No completion handler exists on this path — `becomeCurrent` returns | |
| 96 | /// void — so there is no `ios_async` ticket and the reply goes out inside | |
| 97 | /// the dispatch frame that holds this call's id. | |
| 98 | fn registerShortcut(self: *Self, data: []const u8) !void { | |
| 99 | if (!is_darwin) return BridgeError.PlatformNotSupported; | |
| 100 | ||
| 101 | var fields = try readFields(self.allocator, data, .{ .phrase = true }); | |
| 102 | defer fields.deinit(self.allocator); | |
| 103 | ||
| 104 | const phrase = fields.phrase.?; | |
| 105 | const action = fields.action; | |
| 106 | ||
| 107 | try donate(self.allocator, phrase, action); | |
| 108 | ||
| 109 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 110 | defer out.deinit(self.allocator); | |
| 111 | try out.appendSlice(self.allocator, "{\"registered\":true,\"action\":"); | |
| 112 | try appendJsonString(self.allocator, &out, action); | |
| 113 | try out.appendSlice(self.allocator, ",\"phrase\":"); | |
| 114 | try appendJsonString(self.allocator, &out, phrase); | |
| 115 | try out.append(self.allocator, '}'); | |
| 116 | ||
| 117 | bridge_error.sendResultToJS(self.allocator, A.register_siri_shortcut, out.items); | |
| 118 | } | |
| 119 | ||
| 120 | /// Delete a donated activity, and answer when the deletion is done. | |
| 121 | /// | |
| 122 | /// `deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:` | |
| 123 | /// takes a `void (^)(void)`, and Swift replies from inside it. Replying | |
| 124 | /// before it fired would tell the page a shortcut is gone while Siri can | |
| 125 | /// still match it — the window is small and the claim is still false. | |
| 126 | fn removeShortcut(self: *Self, data: []const u8) !void { | |
| 127 | if (!is_darwin) return BridgeError.PlatformNotSupported; | |
| 128 | ||
| 129 | var fields = try readFields(self.allocator, data, .{ .phrase = false }); | |
| 130 | defer fields.deinit(self.allocator); | |
| 131 | ||
| 132 | const NSUserActivity = objc.objc_getClass("NSUserActivity") orelse | |
| 133 | return BridgeError.PlatformNotSupported; | |
| 134 | const identifiers = try singletonArray(self.allocator, fields.action); | |
| 135 | ||
| 136 | // The reply names the action, so it is shaped before the ticket exists | |
| 137 | // and handed to the slot: the completion carries no arguments at all | |
| 138 | // and has nothing else to reconstruct it from. | |
| 139 | const quoted = try jsonString(std.heap.c_allocator, fields.action); | |
| 140 | defer std.heap.c_allocator.free(quoted); | |
| 141 | const reply = std.fmt.allocPrint( | |
| 142 | std.heap.c_allocator, | |
| 143 | "", | |
| 144 | .{quoted}, | |
| 145 | ) catch return BridgeError.AllocationFailed; | |
| 146 | errdefer std.heap.c_allocator.free(reply); | |
| 147 | ||
| 148 | const ticket = ios_async.acquire(A.remove_siri_shortcut) orelse { | |
| 149 | std.log.warn( | |
| 150 | "removeSiriShortcut: no free reply slot; {d} native calls are already " ++ | |
| 151 | "awaiting one", | |
| 152 | .{ios_async.max_in_flight}, | |
| 153 | ); | |
| 154 | return BridgeError.NativeCallFailed; | |
| 155 | }; | |
| 156 | errdefer ios_async.abandon(ticket); | |
| 157 | ||
| 158 | publishPendingCall(ticket, reply); | |
| 159 | ||
| 160 | const sel = objc.sel_registerName( | |
| 161 | "deleteSavedUserActivitiesWithPersistentIdentifiers:completionHandler:", | |
| 162 | ) orelse return BridgeError.NativeCallFailed; | |
| 163 | const DeleteFn = *const fn (objc.Class, objc.SEL, Id, *anyopaque) callconv(.c) void; | |
| 164 | const deleteFn: DeleteFn = @ptrCast(&objc.objc_msgSend); | |
| 165 | deleteFn(NSUserActivity, sel, identifiers, doneBlock(ticket)); | |
| 166 | } | |
| 167 | }; | |
| 168 | ||
| 169 | /// Build and donate the `NSUserActivity`, in Swift's order. | |
| 170 | fn donate(allocator: std.mem.Allocator, phrase: []const u8, action: []const u8) !void { | |
| 171 | const NSUserActivity = objc.objc_getClass("NSUserActivity") orelse | |
| 172 | return BridgeError.PlatformNotSupported; | |
| 173 | ||
| 174 | const activity_type = try activityType(allocator, action); | |
| 175 | defer allocator.free(activity_type); | |
| 176 | ||
| 177 | const ns_type = objc.createNSString(activity_type, allocator) catch | |
| 178 | return BridgeError.AllocationFailed; | |
| 179 | const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed; | |
| 180 | const sel_init = objc.sel_registerName("initWithActivityType:") orelse | |
| 181 | return BridgeError.NativeCallFailed; | |
| 182 | ||
| 183 | const allocated = objc.msgSendId(NSUserActivity, sel_alloc) orelse | |
| 184 | return BridgeError.NativeCallFailed; | |
| 185 | const activity = objc.msgSendId1(allocated, sel_init, ns_type) orelse | |
| 186 | return BridgeError.NativeCallFailed; | |
| 187 | // ARC releases Swift's local when the function returns, just after | |
| 188 | // `becomeCurrent()`. Same point, same reason. | |
| 189 | defer release(activity); | |
| 190 | ||
| 191 | const ns_phrase = objc.createNSString(phrase, allocator) catch | |
| 192 | return BridgeError.AllocationFailed; | |
| 193 | const ns_action = objc.createNSString(action, allocator) catch | |
| 194 | return BridgeError.AllocationFailed; | |
| 195 | ||
| 196 | try setObject(activity, "setTitle:", ns_phrase); | |
| 197 | try setBool(activity, "setEligibleForSearch:", true); | |
| 198 | try setBool(activity, "setEligibleForPrediction:", true); | |
| 199 | // `NSUserActivityPersistentIdentifier` is a typedef for `NSString *`, so | |
| 200 | // the raw action string is the identifier — which is also what | |
| 201 | // `removeSiriShortcut` deletes by. | |
| 202 | try setObject(activity, "setPersistentIdentifier:", ns_action); | |
| 203 | try setObject(activity, "setSuggestedInvocationPhrase:", ns_phrase); | |
| 204 | try setObject(activity, "setUserInfo:", try userInfo(allocator, ns_action)); | |
| 205 | ||
| 206 | const sel_become = objc.sel_registerName("becomeCurrent") orelse | |
| 207 | return BridgeError.NativeCallFailed; | |
| 208 | objc.msgSend(activity, sel_become); | |
| 209 | } | |
| 210 | ||
| 211 | /// `"<bundle identifier>.<action>"`. | |
| 212 | fn activityType(allocator: std.mem.Allocator, action: []const u8) ![]u8 { | |
| 213 | const NSBundle = objc.objc_getClass("NSBundle") orelse return BridgeError.NativeCallFailed; | |
| 214 | const sel_main = objc.sel_registerName("mainBundle") orelse return BridgeError.NativeCallFailed; | |
| 215 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return BridgeError.NativeCallFailed; | |
| 216 | const sel_identifier = objc.sel_registerName("bundleIdentifier") orelse | |
| 217 | return BridgeError.NativeCallFailed; | |
| 218 | ||
| 219 | // A process with no bundle identifier is a unit-test host or a bare | |
| 220 | // executable, and an activity type of ".doThing" would be donated into a | |
| 221 | // namespace shared with every other such process. Refusing names the real | |
| 222 | // condition. | |
| 223 | const ns_identifier = objc.msgSendId(bundle, sel_identifier) orelse { | |
| 224 | std.log.warn( | |
| 225 | "registerSiriShortcut: this process has no bundle identifier, so the " ++ | |
| 226 | "activity type would not be unique to this app", | |
| 227 | .{}, | |
| 228 | ); | |
| 229 | return BridgeError.NativeCallFailed; | |
| 230 | }; | |
| 231 | const utf8 = objc.getNSStringUTF8(ns_identifier) orelse return BridgeError.NativeCallFailed; | |
| 232 | ||
| 233 | return std.fmt.allocPrint(allocator, "{s}.{s}", .{ std.mem.span(utf8), action }) catch | |
| 234 | BridgeError.AllocationFailed; | |
| 235 | } | |
| 236 | ||
| 237 | /// `@{@"action": action}` — Swift's `activity.userInfo = ["action": action]`. | |
| 238 | fn userInfo(allocator: std.mem.Allocator, ns_action: Id) !Id { | |
| 239 | const NSDictionary = objc.objc_getClass("NSDictionary") orelse | |
| 240 | return BridgeError.NativeCallFailed; | |
| 241 | const sel = objc.sel_registerName("dictionaryWithObject:forKey:") orelse | |
| 242 | return BridgeError.NativeCallFailed; | |
| 243 | const key = objc.createNSString("action", allocator) catch return BridgeError.AllocationFailed; | |
| 244 | return objc.msgSendId2(NSDictionary, sel, ns_action, key) orelse BridgeError.NativeCallFailed; | |
| 245 | } | |
| 246 | ||
| 247 | fn singletonArray(allocator: std.mem.Allocator, value: []const u8) !Id { | |
| 248 | const ns_value = objc.createNSString(value, allocator) catch return BridgeError.AllocationFailed; | |
| 249 | const NSArray = objc.objc_getClass("NSArray") orelse return BridgeError.NativeCallFailed; | |
| 250 | const sel = objc.sel_registerName("arrayWithObject:") orelse return BridgeError.NativeCallFailed; | |
| 251 | return objc.msgSendId1(NSArray, sel, ns_value) orelse BridgeError.NativeCallFailed; | |
| 252 | } | |
| 253 | ||
| 254 | fn setObject(target: Id, comptime selector: [*:0]const u8, value: Id) !void { | |
| 255 | const sel = objc.sel_registerName(selector) orelse return BridgeError.NativeCallFailed; | |
| 256 | objc.msgSendVoid1(target, sel, value); | |
| 257 | } | |
| 258 | ||
| 259 | fn setBool(target: Id, comptime selector: [*:0]const u8, value: bool) !void { | |
| 260 | const sel = objc.sel_registerName(selector) orelse return BridgeError.NativeCallFailed; | |
| 261 | const SetFn = *const fn (Id, objc.SEL, bool) callconv(.c) void; | |
| 262 | const setFn: SetFn = @ptrCast(&objc.objc_msgSend); | |
| 263 | setFn(target, sel, value); | |
| 264 | } | |
| 265 | ||
| 266 | /// The payload fields, owned. | |
| 267 | const Fields = struct { | |
| 268 | action: []u8, | |
| 269 | phrase: ?[]u8, | |
| 270 | ||
| 271 | fn deinit(self: *Fields, allocator: std.mem.Allocator) void { | |
| 272 | allocator.free(self.action); | |
| 273 | if (self.phrase) |p| allocator.free(p); | |
| 274 | } | |
| 275 | }; | |
| 276 | ||
| 277 | /// Read `action`, and `phrase` when the caller needs it. | |
| 278 | /// | |
| 279 | /// Both Swift arms are `if let … as? String` chains with no `else`, so a | |
| 280 | /// missing or mistyped field replies nothing and the page waits out its | |
| 281 | /// timeout. Not carried across: every path here ends in a value or an error. | |
| 282 | fn readFields( | |
| 283 | allocator: std.mem.Allocator, | |
| 284 | data: []const u8, | |
| 285 | comptime want: struct { phrase: bool }, | |
| 286 | ) !Fields { | |
| 287 | var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) { | |
| 288 | error.OutOfMemory => return err, | |
| 289 | else => return BridgeError.InvalidJSON, | |
| 290 | }; | |
| 291 | defer parsed.deinit(); | |
| 292 | ||
| 293 | const object = switch (parsed.value) { | |
| 294 | .object => |o| o, | |
| 295 | else => return BridgeError.InvalidJSON, | |
| 296 | }; | |
| 297 | ||
| 298 | const action = try requiredString(allocator, object, "action"); | |
| 299 | errdefer allocator.free(action); | |
| 300 | ||
| 301 | const phrase = if (want.phrase) try requiredString(allocator, object, "phrase") else null; | |
| 302 | ||
| 303 | return .{ .action = action, .phrase = phrase }; | |
| 304 | } | |
| 305 | ||
| 306 | fn requiredString( | |
| 307 | allocator: std.mem.Allocator, | |
| 308 | object: std.json.ObjectMap, | |
| 309 | key: []const u8, | |
| 310 | ) ![]u8 { | |
| 311 | const value = object.get(key) orelse return BridgeError.MissingData; | |
| 312 | const text = switch (value) { | |
| 313 | .string => |s| s, | |
| 314 | else => return BridgeError.InvalidParameter, | |
| 315 | }; | |
| 316 | // An empty action would donate an activity type ending in a dot and delete | |
| 317 | // by an identifier that matches nothing. | |
| 318 | if (text.len == 0) return BridgeError.InvalidParameter; | |
| 319 | return allocator.dupe(u8, text) catch BridgeError.AllocationFailed; | |
| 320 | } | |
| 321 | ||
| 322 | fn appendJsonString( | |
| 323 | allocator: std.mem.Allocator, | |
| 324 | out: *std.ArrayListUnmanaged(u8), | |
| 325 | s: []const u8, | |
| 326 | ) !void { | |
| 327 | try out.append(allocator, '"'); | |
| 328 | try bridge_error.appendJsonEscaped(allocator, out, s); | |
| 329 | try out.append(allocator, '"'); | |
| 330 | } | |
| 331 | ||
| 332 | fn jsonString(allocator: std.mem.Allocator, s: []const u8) ![]u8 { | |
| 333 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 334 | errdefer out.deinit(allocator); | |
| 335 | try appendJsonString(allocator, &out, s); | |
| 336 | return out.toOwnedSlice(allocator); | |
| 337 | } | |
| 338 | ||
| 339 | fn release(object: Id) void { | |
| 340 | const target = object orelse return; | |
| 341 | const sel = objc.sel_registerName("release") orelse return; | |
| 342 | objc.msgSend(target, sel); | |
| 343 | } | |
| 344 | ||
| 345 | // --------------------------------------------------------------------------- | |
| 346 | // The deletion completion | |
| 347 | // --------------------------------------------------------------------------- | |
| 348 | ||
| 349 | /// The reply a slot's completion will send. | |
| 350 | /// | |
| 351 | /// `void (^)(void)` carries nothing — not even a success flag — so everything | |
| 352 | /// the reply needs is shaped before the call and parked here. The ticket is | |
| 353 | /// stored rather than rebuilt from the slot index, because a `Ticket` is an | |
| 354 | /// index *and* a generation and inventing the generation would defeat the | |
| 355 | /// check that stops a late completion answering whoever holds the slot now. | |
| 356 | const PendingCall = struct { | |
| 357 | ticket: ios_async.Ticket, | |
| 358 | /// Owned, `c_allocator`. Freed when the completion fires. | |
| 359 | reply: []u8, | |
| 360 | }; | |
| 361 | ||
| 362 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); | |
| 363 | var pending_mutex: compat_mutex.Mutex = .{}; | |
| 364 | ||
| 365 | fn publishPendingCall(ticket: ios_async.Ticket, reply: []u8) void { | |
| 366 | pending_mutex.lock(); | |
| 367 | defer pending_mutex.unlock(); | |
| 368 | pending_calls[ticket.index] = .{ .ticket = ticket, .reply = reply }; | |
| 369 | } | |
| 370 | ||
| 371 | /// Read and clear. Clearing is what makes a second fire of the same completion | |
| 372 | /// a no-op rather than a second reply and a double free. | |
| 373 | fn takePendingCall(index: u5) ?PendingCall { | |
| 374 | pending_mutex.lock(); | |
| 375 | defer pending_mutex.unlock(); | |
| 376 | const call = pending_calls[index]; | |
| 377 | pending_calls[index] = null; | |
| 378 | return call; | |
| 379 | } | |
| 380 | ||
| 381 | const BlockDescriptor = extern struct { | |
| 382 | reserved: c_ulong = 0, | |
| 383 | size: c_ulong, | |
| 384 | }; | |
| 385 | ||
| 386 | /// `void (^)(void)` — no arguments at all, which is why none of `ios_async`'s | |
| 387 | /// pre-built blocks fit. | |
| 388 | const DoneBlock = extern struct { | |
| 389 | isa: ?*anyopaque, | |
| 390 | flags: c_int, | |
| 391 | reserved: c_int = 0, | |
| 392 | invoke: *const anyopaque, | |
| 393 | descriptor: *const BlockDescriptor, | |
| 394 | }; | |
| 395 | ||
| 396 | /// 1 << 28. A global block is never copied, so it can be handed to an API that | |
| 397 | /// escapes it with no heap copy and no descriptor lifetime. | |
| 398 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 399 | ||
| 400 | const done_block_descriptor = BlockDescriptor{ .size = @sizeOf(DoneBlock) }; | |
| 401 | ||
| 402 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 403 | ||
| 404 | fn makeDoneInvoke(comptime index: u5) *const anyopaque { | |
| 405 | const S = struct { | |
| 406 | fn invoke(_: *const DoneBlock) callconv(.c) void { | |
| 407 | deletionFired(index); | |
| 408 | } | |
| 409 | }; | |
| 410 | return @ptrCast(&S.invoke); | |
| 411 | } | |
| 412 | ||
| 413 | fn makeDoneBlocks() [ios_async.max_in_flight]DoneBlock { | |
| 414 | var out: [ios_async.max_in_flight]DoneBlock = undefined; | |
| 415 | for (&out, 0..) |*b, i| { | |
| 416 | b.* = .{ | |
| 417 | .isa = &_NSConcreteGlobalBlock, | |
| 418 | .flags = BLOCK_IS_GLOBAL, | |
| 419 | .invoke = makeDoneInvoke(@intCast(i)), | |
| 420 | .descriptor = &done_block_descriptor, | |
| 421 | }; | |
| 422 | } | |
| 423 | return out; | |
| 424 | } | |
| 425 | ||
| 426 | var done_blocks: [ios_async.max_in_flight]DoneBlock = | |
| 427 | if (is_darwin) makeDoneBlocks() else undefined; | |
| 428 | ||
| 429 | fn doneBlock(ticket: ios_async.Ticket) *anyopaque { | |
| 430 | return @ptrCast(&done_blocks[ticket.index]); | |
| 431 | } | |
| 432 | ||
| 433 | /// Runs on whatever queue Foundation chose. `ios_async.deliverJson` copies the | |
| 434 | /// payload and hops to the main queue, where `evaluateJavaScript:` is legal. | |
| 435 | fn deletionFired(index: u5) void { | |
| 436 | if (!is_darwin) return; | |
| 437 | ||
| 438 | const call = takePendingCall(index) orelse { | |
| 439 | std.log.warn( | |
| 440 | "removeSiriShortcut completion fired for slot {d} with no call recorded; ignored", | |
| 441 | .{index}, | |
| 442 | ); | |
| 443 | return; | |
| 444 | }; | |
| 445 | defer std.heap.c_allocator.free(call.reply); | |
| 446 | ||
| 447 | ios_async.deliverJson(call.ticket, call.reply); | |
| 448 | } | |
| 449 | ||
| 450 | const testing = std.testing; | |
| 451 | ||
| 452 | test "the action names match the Swift case labels exactly" { | |
| 453 | try testing.expectEqualStrings("registerSiriShortcut", A.register_siri_shortcut); | |
| 454 | try testing.expectEqualStrings("removeSiriShortcut", A.remove_siri_shortcut); | |
| 455 | } | |
| 456 | ||
| 457 | test "both fields are required for a registration, and neither hangs" { | |
| 458 | // Swift's arm is a two-clause `if let … as? String` with no else, so a | |
| 459 | // missing phrase replies nothing at all. | |
| 460 | var fields = try readFields( | |
| 461 | testing.allocator, | |
| 462 | "{\"action\":\"openVault\",\"phrase\":\"open my vault\"}", | |
| 463 | .{ .phrase = true }, | |
| 464 | ); | |
| 465 | defer fields.deinit(testing.allocator); | |
| 466 | try testing.expectEqualStrings("openVault", fields.action); | |
| 467 | try testing.expectEqualStrings("open my vault", fields.phrase.?); | |
| 468 | ||
| 469 | try testing.expectError(BridgeError.MissingData, readFields( | |
| 470 | testing.allocator, | |
| 471 | "{\"action\":\"openVault\"}", | |
| 472 | .{ .phrase = true }, | |
| 473 | )); | |
| 474 | try testing.expectError(BridgeError.MissingData, readFields( | |
| 475 | testing.allocator, | |
| 476 | "{\"phrase\":\"open my vault\"}", | |
| 477 | .{ .phrase = true }, | |
| 478 | )); | |
| 479 | try testing.expectError(BridgeError.InvalidParameter, readFields( | |
| 480 | testing.allocator, | |
| 481 | "{\"action\":\"\",\"phrase\":\"p\"}", | |
| 482 | .{ .phrase = true }, | |
| 483 | )); | |
| 484 | try testing.expectError(BridgeError.InvalidParameter, readFields( | |
| 485 | testing.allocator, | |
| 486 | "{\"action\":3,\"phrase\":\"p\"}", | |
| 487 | .{ .phrase = true }, | |
| 488 | )); | |
| 489 | } | |
| 490 | ||
| 491 | test "a removal needs only the action" { | |
| 492 | var fields = try readFields(testing.allocator, "{\"action\":\"openVault\"}", .{ .phrase = false }); | |
| 493 | defer fields.deinit(testing.allocator); | |
| 494 | try testing.expectEqualStrings("openVault", fields.action); | |
| 495 | try testing.expect(fields.phrase == null); | |
| 496 | } | |
| 497 | ||
| 498 | test "an action carrying a quote survives into the reply escaped" { | |
| 499 | // The reply is replayed into the source `evaluateJavaScript:` parses, so | |
| 500 | // an unescaped quote is a syntax error in the page rather than a wrong | |
| 501 | // field. | |
| 502 | const quoted = try jsonString(testing.allocator, "open \"my\" vault\\"); | |
| 503 | defer testing.allocator.free(quoted); | |
| 504 | try testing.expectEqualStrings("\"open \\\"my\\\" vault\\\\\"", quoted); | |
| 505 | } | |
| 506 | ||
| 507 | test "every declared action dispatches to something" { | |
| 508 | var bridge = SiriBridge.init(testing.allocator); | |
| 509 | defer bridge.deinit(); | |
| 510 | ||
| 511 | for (capability_actions) |decl| { | |
| 512 | bridge.handleMessage(decl.name, "{}") catch |err| { | |
| 513 | try testing.expect(err != BridgeError.UnknownAction); | |
| 514 | continue; | |
| 515 | }; | |
| 516 | } | |
| 517 | try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("setShortcuts", "{}")); | |
| 518 | } | |
| 519 | ||
| 520 | test "a refused removal leases no reply slot" { | |
| 521 | if (!is_darwin) return error.SkipZigTest; | |
| 522 | ||
| 523 | var bridge = SiriBridge.init(testing.allocator); | |
| 524 | defer bridge.deinit(); | |
| 525 | // No `action`, so it refuses before the ticket is taken. | |
| 526 | bridge.handleMessage(A.remove_siri_shortcut, "{}") catch {}; | |
| 527 | ||
| 528 | pending_mutex.lock(); | |
| 529 | defer pending_mutex.unlock(); | |
| 530 | for (pending_calls) |entry| try testing.expect(entry == null); | |
| 531 | } | |
| 532 | ||
| 533 | test "each slot's completion block is global and has its own invoke" { | |
| 534 | if (!is_darwin) return error.SkipZigTest; | |
| 535 | ||
| 536 | for (&done_blocks) |*b| { | |
| 537 | try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); | |
| 538 | try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); | |
| 539 | try testing.expectEqual(@sizeOf(DoneBlock), @as(usize, @intCast(b.descriptor.size))); | |
| 540 | } | |
| 541 | try testing.expect(done_blocks[0].invoke != done_blocks[1].invoke); | |
| 542 | } | |
| 543 | ||
| 544 | test "a completion for a slot with no recorded call is ignored" { | |
| 545 | // `void (^)(void)` carries nothing, so a stray fire has no way to identify | |
| 546 | // itself. Clearing the entry on take is what makes the second fire a | |
| 547 | // no-op rather than a double free of the parked reply. | |
| 548 | if (!is_darwin) return error.SkipZigTest; | |
| 549 | ||
| 550 | pending_mutex.lock(); | |
| 551 | for (&pending_calls) |*entry| entry.* = null; | |
| 552 | pending_mutex.unlock(); | |
| 553 | ||
| 554 | deletionFired(0); | |
| 555 | deletionFired(0); | |
| 556 | } | |
| @@ -399,6 +399,7 @@ fn bundleResourcePath() ?[*:0]const u8 { | ||
| 399 | 399 | pub fn gateFor(action: []const u8) ?Feature { |
| 400 | 400 | const table = comptime [_]struct { []const u8, Feature }{ |
| 401 | 401 | .{ "addContact", .contacts }, |
| 402 | .{ "authenticate", .biometric }, | |
| 402 | 403 | .{ "cancelAllBackgroundTasks", .background_tasks }, |
| 403 | 404 | .{ "cancelAllNotifications", .local_notifications }, |
| 404 | 405 | .{ "cancelBackgroundTask", .background_tasks }, |
| @@ -33,6 +33,8 @@ const bridge_mobile_contactpicker = ; | ||
| 33 | 33 | const bridge_mobile_calendar = ; |
| 34 | 34 | const bridge_mobile_contacts = ; |
| 35 | 35 | const bridge_mobile_vision = ; |
| 36 | const bridge_mobile_auth = ; | |
| 37 | const bridge_mobile_siri = ; | |
| 36 | 38 | // `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its |
| 37 | 39 | // capability_actions table is empty — the two speech actions were researched |
| 38 | 40 | // in full and left with the Swift shim — so a module here would iterate, |
| @@ -275,6 +277,8 @@ const mobile_bridges = .{ | ||
| 275 | 277 | bridge_mobile_calendar.CalendarBridge, |
| 276 | 278 | bridge_mobile_contacts.ContactsBridge, |
| 277 | 279 | bridge_mobile_vision.VisionBridge, |
| 280 | bridge_mobile_auth.AuthBridge, | |
| 281 | bridge_mobile_siri.SiriBridge, | |
| 278 | 282 | }; |
| 279 | 283 | |
| 280 | 284 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| @@ -50,6 +50,8 @@ const zig_sources = [_][]const u8{ | ||
| 50 | 50 | @embedFile("src/bridge_mobile_calendar.zig"), |
| 51 | 51 | @embedFile("src/bridge_mobile_contacts.zig"), |
| 52 | 52 | @embedFile("src/bridge_mobile_vision.zig"), |
| 53 | @embedFile("src/bridge_mobile_auth.zig"), | |
| 54 | @embedFile("src/bridge_mobile_siri.zig"), | |
| 53 | 55 | @embedFile("src/bridge_mobile_speech.zig"), |
| 54 | 56 | }; |
| 55 | 57 | |
| @@ -94,7 +96,7 @@ const dispatch_end = "func webView("; | ||
| 94 | 96 | /// 48 after calendar and contacts. Speech was researched in full and left |
| 95 | 97 | /// with the shim: neither action replies, so unlike the earlier deferrals |
| 96 | 98 | /// there is no promise to strand and falling through costs the page nothing. |
| 97 | const max_not_yet_migrated: usize = 43; | |
| 99 | const max_not_yet_migrated: usize = 40; | |
| 98 | 100 | |
| 99 | 101 | fn dispatcherRegion() []const u8 { |
| 100 | 102 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |