ReviewOS

also looking at this

craft-native/craft

iOS: biometric prompt and Siri shortcuts — ratchet 43 → 40

#94
Merged glennmichael123 wants to merge feat/ios-auth-siri into main
9 files +1090 -3
packages/ios/fixtures/zig-slice/build-and-run.shmodified+17-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -63,7 +63,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6363 "$OUT/main.o" "$OUT/page.o" \
6464 "$ROOT/packages/zig/zig-out/lib/$LIB" \
6565 -framework UIKit -framework WebKit -framework Foundation -framework Security \
66 -framework Vision \
66 -framework Vision -framework LocalAuthentication \
6767 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6868 -o "$APP/CraftSlice"
6969
@@ -100,6 +100,7 @@ LAUNCH_PID=$!
100100for _ in $(seq 1 60); do
101101 if grep -q 'i=26' "$LOG" 2>/dev/null && grep -q 'i=28' "$LOG" 2>/dev/null \
102102 && 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 \
103104 && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null; then sleep 1; break; fi
104105 sleep 1
105106done
@@ -267,4 +268,19 @@ C32="$(count 32)"
267268[ "$C32" -ge 1 ] || { echo "FAIL: detectObjects neither answered nor failed — it hung"; exit 1; }
268269echo "ok: detectObjects answered rather than hanging (i=32 seen ${C32}x)"
269270
271C34="$(count 34)"
272[ "$C34" -ge 1 ] || { echo "FAIL: authenticate did not refuse with a specific code"; exit 1; }
273echo "ok: biometrics refused by the framework, not by a missing class (i=34 seen ${C34}x)"
274
275C36="$(count 36)"
276[ "$C36" -ge 1 ] || { echo "FAIL: registerSiriShortcut did not echo the donated shortcut"; exit 1; }
277echo "ok: NSUserActivity donated and echoed back (i=36 seen ${C36}x)"
278
279C38="$(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; }
284echo "ok: argument-free completion block delivered its parked reply (i=38 seen ${C38}x)"
285
270286echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -7,7 +7,7 @@
77 "enableHaptics": false,
88 "enableShare": false,
99 "enableCamera": true,
10 "enableBiometric": false,
10 "enableBiometric": true,
1111 "enablePushNotifications": false,
1212 "enableSecureStorage": true,
1313 "enableGeolocation": false,
packages/ios/fixtures/zig-slice/page.mmodified+36-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -48,6 +48,7 @@ const char craft_slice_page[] =
4848 "var sentErrorAck = false, sentCode = false, sentDisabled = false;\n"
4949 "var sentShot = false, sentDeepLink = false;\n"
5050 "var sentOcr = false, sentClassify = false, sentDetect = false;\n"
51 "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n"
5152 "window.__craftBridgeError = function (ctx) {\n"
5253 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5354 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -67,6 +68,15 @@ const char craft_slice_page[] =
6768 " // that they ANSWER: before this phase both were gated in Swift with no\n"
6869 " // else, so the page's promise hung. Either a real result or a named\n"
6970 " // 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"
7080 " if (!sentClassify && ctx && ctx.error === true && ctx.id === 93) {\n"
7181 " sentClassify = true;\n"
7282 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:28});\n"
@@ -149,6 +159,25 @@ const char craft_slice_page[] =
149159 " // 'CRAFT' from an image it never decoded, and the reply had to survive\n"
150160 " // base64 -> NSData -> UIImage -> CGImage -> VNRecognizeTextRequest and\n"
151161 " // 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"
152181 " if (!sentOcr && action === 'recognizeText' && Array.isArray(payload)) {\n"
153182 " var joined = payload.map(function (r) { return r.text; }).join(' ').toUpperCase();\n"
154183 " var box = payload.length ? payload[0].boundingBox : null;\n"
@@ -227,6 +256,13 @@ const char craft_slice_page[] =
227256 " bridge.postMessage({t:'mobile', a:'recognizeText', d: JSON.stringify({image:b64}), i:92});\n"
228257 " bridge.postMessage({t:'mobile', a:'classifyImage', d: JSON.stringify({image:b64}), i:93});\n"
229258 " 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"
230266 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
231267 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
232268 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+6-0
Changes to packages/zig/build.zig
@@ -404,6 +404,12 @@ pub fn build(b: *std.Build) void {
404404 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_vision.zig", .{
405405 .root_source_file = b.path("src/bridge_mobile_vision.zig"),
406406 });
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 });
407413 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
408414 .root_source_file = b.path("src/bridge_mobile.zig"),
409415 });
packages/zig/src/bridge_mobile_auth.zigadded+466-0
Changes to packages/zig/src/bridge_mobile_auth.zig
@@ -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
50const std = ;
51const builtin = ;
52const capabilities = ;
53const bridge_error = ;
54const objc_runtime = ;
55const ios_async = ;
56const compat_mutex = ;
57
58const objc = objc_runtime.objc;
59const BridgeError = bridge_error.BridgeError;
60const Id = ?*anyopaque;
61const is_darwin = builtin.target.os.tag.isDarwin();
62
63pub const A = struct {
64 pub const authenticate = "authenticate";
65};
66
67pub 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.
77const policy_biometrics: c_long = 1;
78
79/// `LAErrorUserCancel`. The user pressed Cancel on the prompt.
80const 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.
88const 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.
96const success_reply = "true";
97
98pub 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.
166fn 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.
183fn 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.
220const PendingCall = struct {
221 ticket: ios_async.Ticket,
222 context: Id,
223};
224
225var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
226var 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.
230fn 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.
239fn 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
247const 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"`.
257const 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.
267const BLOCK_IS_GLOBAL: c_int = 1 << 28;
268
269const reply_block_descriptor = BlockDescriptor{ .size = @sizeOf(ReplyBlock) };
270
271extern var _NSConcreteGlobalBlock: anyopaque;
272
273fn 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
282fn 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
295var reply_blocks: [ios_async.max_in_flight]ReplyBlock =
296 if (is_darwin) makeReplyBlocks() else undefined;
297
298fn 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.
308fn 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
336fn 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
344fn 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
355fn 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
361const testing = std.testing;
362
363test "the action name matches the Swift case label exactly" {
364 try testing.expectEqualStrings("authenticate", A.authenticate);
365}
366
367test "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
375test "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
383test "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
412test "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
425test "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
439test "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
454test "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
Changes to packages/zig/src/bridge_mobile_siri.zig
@@ -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
49const std = ;
50const builtin = ;
51const capabilities = ;
52const bridge_error = ;
53const objc_runtime = ;
54const ios_async = ;
55const compat_mutex = ;
56
57const objc = objc_runtime.objc;
58const BridgeError = bridge_error.BridgeError;
59const Id = ?*anyopaque;
60const is_darwin = builtin.target.os.tag.isDarwin();
61
62pub const A = struct {
63 pub const register_siri_shortcut = "registerSiriShortcut";
64 pub const remove_siri_shortcut = "removeSiriShortcut";
65};
66
67pub const capability_actions = [_]capabilities.ActionDecl{
68 .{ .name = A.register_siri_shortcut, .reply = .result },
69 .{ .name = A.remove_siri_shortcut, .reply = .result },
70};
71
72pub 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.
170fn 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>"`.
212fn 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]`.
238fn 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
247fn 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
254fn 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
259fn 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.
267const 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.
282fn 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
306fn 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
322fn 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
332fn 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
339fn 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.
356const PendingCall = struct {
357 ticket: ios_async.Ticket,
358 /// Owned, `c_allocator`. Freed when the completion fires.
359 reply: []u8,
360};
361
362var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
363var pending_mutex: compat_mutex.Mutex = .{};
364
365fn 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.
373fn 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
381const 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.
388const 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.
398const BLOCK_IS_GLOBAL: c_int = 1 << 28;
399
400const done_block_descriptor = BlockDescriptor{ .size = @sizeOf(DoneBlock) };
401
402extern var _NSConcreteGlobalBlock: anyopaque;
403
404fn 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
413fn 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
426var done_blocks: [ios_async.max_in_flight]DoneBlock =
427 if (is_darwin) makeDoneBlocks() else undefined;
428
429fn 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.
435fn 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
450const testing = std.testing;
451
452test "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
457test "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
491test "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
498test "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
507test "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
520test "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
533test "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
544test "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}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -399,6 +399,7 @@ fn bundleResourcePath() ?[*:0]const u8 {
399399pub fn gateFor(action: []const u8) ?Feature {
400400 const table = comptime [_]struct { []const u8, Feature }{
401401 .{ "addContact", .contacts },
402 .{ "authenticate", .biometric },
402403 .{ "cancelAllBackgroundTasks", .background_tasks },
403404 .{ "cancelAllNotifications", .local_notifications },
404405 .{ "cancelBackgroundTask", .background_tasks },
packages/zig/src/ios_dispatch.zigmodified+4-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -33,6 +33,8 @@ const bridge_mobile_contactpicker = ;
3333const bridge_mobile_calendar = ;
3434const bridge_mobile_contacts = ;
3535const bridge_mobile_vision = ;
36const bridge_mobile_auth = ;
37const bridge_mobile_siri = ;
3638// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
3739// capability_actions table is empty — the two speech actions were researched
3840// in full and left with the Swift shim — so a module here would iterate,
@@ -275,6 +277,8 @@ const mobile_bridges = .{
275277 bridge_mobile_calendar.CalendarBridge,
276278 bridge_mobile_contacts.ContactsBridge,
277279 bridge_mobile_vision.VisionBridge,
280 bridge_mobile_auth.AuthBridge,
281 bridge_mobile_siri.SiriBridge,
278282};
279283
280284/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+3-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -50,6 +50,8 @@ const zig_sources = [_][]const u8{
5050 @embedFile("src/bridge_mobile_calendar.zig"),
5151 @embedFile("src/bridge_mobile_contacts.zig"),
5252 @embedFile("src/bridge_mobile_vision.zig"),
53 @embedFile("src/bridge_mobile_auth.zig"),
54 @embedFile("src/bridge_mobile_siri.zig"),
5355 @embedFile("src/bridge_mobile_speech.zig"),
5456};
5557
@@ -94,7 +96,7 @@ const dispatch_end = "func webView(";
9496/// 48 after calendar and contacts. Speech was researched in full and left
9597/// with the shim: neither action replies, so unlike the earlier deferrals
9698/// there is no promise to strand and falling through costs the page nothing.
97const max_not_yet_migrated: usize = 43;
99const max_not_yet_migrated: usize = 40;
98100
99101fn dispatcherRegion() []const u8 {
100102 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";