ReviewOS

also looking at this

craft-native/craft

iOS Phase 4: the secure tier — async replies, Keychain, permissions; ratchet 86 → 74

#86
Merged glennmichael123 wants to merge feat/ios-phase4-secure into main
11 files +4297 -4
packages/ios/fixtures/zig-slice/build-and-run.shmodified+29-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -33,6 +33,24 @@ clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \
3333clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \
3434 "$FIXTURE/page.m" -o "$OUT/page.o"
3535
36# Simulator keychain identity. On the simulator, application-identifier and
37# keychain-access-groups are NOT signed in — a signature carrying them is
38# refused at launch (POSIX 163 from RunningBoard, bisected: the same signature
39# without them launches). Xcode instead embeds them as a __TEXT,__entitlements
40# section and the simulator's security layer reads them from there. Without
41# any identity, SecItemAdd fails with errSecMissingEntitlement (-34018).
42cat > "$OUT/sim.entitlements" <<'ENTS'
43<?xml version="1.0" encoding="UTF-8"?>
44<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45<plist version="1.0">
46<dict>
47 <key>application-identifier</key><string>SLICE00000.app.craft.slice</string>
48 <key>keychain-access-groups</key>
49 <array><string>SLICE00000.app.craft.slice</string></array>
50</dict>
51</plist>
52ENTS
53
3654echo "==> linking CraftSlice"
3755# The shim class has to survive into the binary for
3856# `objc_getClass("CraftSwiftShim")` to find it at runtime. Compiling it into
@@ -45,10 +63,16 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
4563 "$OUT/main.o" "$OUT/page.o" \
4664 "$ROOT/packages/zig/zig-out/lib/$LIB" \
4765 -framework UIKit -framework WebKit -framework Foundation -framework Security \
66 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
4867 -o "$APP/CraftSlice"
4968
5069cp "$FIXTURE/Info.plist" "$APP/Info.plist"
5170
71# Ad-hoc signature. The identity entitlements live in the __entitlements
72# section above; signing them in instead makes the launch be denied.
73codesign --force --sign - "$APP"
74
75
5276echo "==> booting a simulator"
5377UDID="$(xcrun simctl list devices available -j \
5478 | python3 -c 'import json,sys;d=json.load(sys.stdin)["devices"];print(next(x["udid"] for k in d for x in d[k] if "iPhone" in x["name"]))')"
@@ -66,7 +90,7 @@ LAUNCH_PID=$!
6690# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
6791# fixed amount, so a slow boot does not read as a failure.
6892for _ in $(seq 1 60); do
69 if grep -q 'i=7' "$LOG" 2>/dev/null && grep -q 'i=8' "$LOG" 2>/dev/null; then sleep 1; break; fi
93 if grep -q 'i=7' "$LOG" 2>/dev/null && grep -q 'i=10' "$LOG" 2>/dev/null; then sleep 1; break; fi
7094 sleep 1
7195done
7296kill "$LAUNCH_PID" 2>/dev/null || true
@@ -126,4 +150,8 @@ C8="$(count 8)"
126150[ "$C8" -ge 1 ] || { echo "FAIL: the Tier-0 action's reply never carried a real app state"; exit 1; }
127151echo "ok: Tier-0 action served by Zig with a live UIKit value (i=8 seen ${C8}x)"
128152
153C10="$(count 10)"
154[ "$C10" -ge 1 ] || { echo "FAIL: the Keychain round trip never completed byte-identical"; exit 1; }
155echo "ok: secureSet/secureGet round-tripped a secret through the Keychain (i=10 seen ${C10}x)"
156
129157echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+16-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -13,6 +13,9 @@
1313// i=5 that reply came back, having been answered by the host shim
1414// i=8 the page called a Tier-0 action Zig newly serves (getAppState) and
1515// its reply named a real UIKit application state
16// i=9 secureSet stored a secret in the real Keychain and replied true
17// i=10 secureGet read the same secret back, byte-identical — proving the
18// write went through SecItemAdd and the escaping survived both ways
1619// i=6 the page called an action the shim answers by *rejecting*
1720// i=7 the rejection arrived via __craftBridgeError with its id, code, and
1821// an unmangled message — proving the error route and Zig-side escaping
@@ -27,7 +30,7 @@ const char craft_slice_page[] =
2730 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
2831 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
2932 "</head><body><h1 id=\"s\">craft slice</h1><script>\n"
30 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false;\n"
33 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false;\n"
3134 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
3235 "\n"
3336 "// Read before anything else touches it: the user script is installed at\n"
@@ -50,6 +53,7 @@ const char craft_slice_page[] =
5053 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:7});\n"
5154 " }\n"
5255 "};\n"
56 "var SECRET = 'sl\\\\ice \"v\" 1';\n"
5357 "window.__craftBridgeResult = function (action, payload, id) {\n"
5458 " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n"
5559 " document.getElementById('s').textContent = window.__craftSliceAck;\n"
@@ -66,6 +70,13 @@ const char craft_slice_page[] =
6670 " // protocol it uses for its own actions.\n"
6771 " // A Tier-0 action served by Zig itself: the reply carries the UIKit\n"
6872 " // application state, which only a live foregrounded process reports.\n"
73 " if (action === 'secureSet' && payload === true) {\n"
74 " bridge.postMessage({t:'mobile', a:'secureGet', d: JSON.stringify({key:'slice-key'}), i:31});\n"
75 " }\n"
76 " if (!sentSecure && action === 'secureGet' && payload === SECRET) {\n"
77 " sentSecure = true;\n"
78 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:10});\n"
79 " }\n"
6980 " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n"
7081 " sentTierZero = true;\n"
7182 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n"
@@ -81,6 +92,10 @@ const char craft_slice_page[] =
8192 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n"
8293 " // An action Zig does not serve, so it must reach the host shim.\n"
8394 " bridge.postMessage({t:'mobile', a:'hostOnlyPing', i:4});\n"
95 " // Round-trip a secret through the real Keychain: set, then get on the\n"
96 " // reply, then confirm byte-identity. The secret carries a quote and a\n"
97 " // backslash so the escaping is proven in both directions.\n"
98 " bridge.postMessage({t:'mobile', a:'secureSet', d: JSON.stringify({key:'slice-key', value:SECRET}), i:30});\n"
8499 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
85100 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
86101 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+15-0
Changes to packages/zig/build.zig
@@ -409,6 +409,21 @@ pub fn build(b: *std.Build) void {
409409 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_storage.zig", .{
410410 .root_source_file = b.path("src/bridge_mobile_storage.zig"),
411411 });
412 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_misc.zig", .{
413 .root_source_file = b.path("src/bridge_mobile_misc.zig"),
414 });
415 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_shortcuts.zig", .{
416 .root_source_file = b.path("src/bridge_mobile_shortcuts.zig"),
417 });
418 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_securestore.zig", .{
419 .root_source_file = b.path("src/bridge_mobile_securestore.zig"),
420 });
421 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_biometric.zig", .{
422 .root_source_file = b.path("src/bridge_mobile_biometric.zig"),
423 });
424 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_permissions.zig", .{
425 .root_source_file = b.path("src/bridge_mobile_permissions.zig"),
426 });
412427
413428 const menubar_tests = b.addTest(.{
414429 .root_module = b.createModule(.{
packages/zig/src/bridge_mobile_biometric.zigadded+658-0
Changes to packages/zig/src/bridge_mobile_biometric.zig
@@ -0,0 +1,658 @@
1//! The biometric "session persistence" actions of the `mobile` namespace:
2//! `setBiometricPersistence`, `checkBiometricPersistence`,
3//! `clearBiometricPersistence`.
4//!
5//! **Despite the name, there is no Keychain, no `SecAccessControl` and no
6//! `LAContext` here.** Swift implements all three against one in-memory field —
7//! `private var biometricSessionExpiry: Date?` (CraftApp.swift:4434) — and
8//! nothing else in that file reads it: the actual biometric prompt never
9//! consults it. The "session" is bookkeeping the page asked to be kept and
10//! reads back, which makes this the rare mobile action set that is pure Zig —
11//! module state plus a millisecond clock, no Objective-C at all, and therefore
12//! no Darwin gate anywhere in this file. It behaves identically on every
13//! platform the dispatch chain compiles for.
14//!
15//! ## What is carried across exactly, because it is the observable contract
16//!
17//! - **The reply shapes**, per `craft.d.ts:1256` and the Swift dictionary
18//! literals: enable answers `{"enabled":true,"duration":…,"expiresAt":…}`,
19//! disable `{"enabled":false}`, check `{"isValid":…,"remainingSeconds":…}`,
20//! clear `{"cleared":true}` — always, even when nothing was set.
21//! - **The unit mismatch.** `expiresAt` is epoch *milliseconds*
22//! (`timeIntervalSince1970 * 1000`); `remainingSeconds` is *seconds*.
23//! Ugly, shipped, load-bearing.
24//! - **An absent `duration` defaults to 300**, in agreement with both layers
25//! above this one: the injected JS posts `duration || 300` and the Swift
26//! dispatcher falls back with `?? 300`. A *present* `0` or negative reaches
27//! Swift as itself and makes an instantly-expired session, so it does here.
28//! - **`check` never clears an expired session.** It answers
29//! `{"isValid":false,"remainingSeconds":0}` and leaves the stale expiry in
30//! place, exactly as Swift leaves the stale `Date`. Only an explicit
31//! `clear` or a disable drops it. Expired and never-set are the same reply,
32//! indistinguishable by design.
33//! - **Strict `<` at the boundary**: Swift's `Date() < expiry` means the
34//! session is already invalid at the exact instant it expires.
35//!
36//! ## What is deliberately not carried across
37//!
38//! **Swift's silent hang.** The dispatcher arm is
39//! `if let enabled = body["enabled"] as? Bool` with no else
40//! (CraftApp.swift:1003), so a missing or non-bool `enabled` replies nothing
41//! and the page's promise never settles. Here it is `MissingData` /
42//! `InvalidParameter`.
43//!
44//! **Swift's silent re-typing of `duration`.** `as? Double ?? 300` rewrites a
45//! present-but-non-numeric `duration` into 300 and reports success — a payload
46//! field the page sent, replaced with a different value. Here it is
47//! `InvalidParameter`; only *absence* defaults, because absence is what the
48//! injected JS actually sends on the disable path.
49//!
50//! **`biometricSessionDuration`** (CraftApp.swift:4435) — written on every
51//! enable, read by nothing, ever. Not replicated.
52//!
53//! ## The one migration hazard: the state must move whole
54//!
55//! The expiry lives in whichever language serves the action. If Zig served
56//! `check` while the un-migrated Swift shim still served `set`, the two would
57//! each hold their own expiry and a session Swift enabled would check as
58//! invalid. All three actions therefore live in this one module and enter
59//! `mobile_bridges` together; serving a subset of them from Zig is the one
60//! wrong way to deploy this file.
61//!
62//! The state is a module-level `?f64` with no lock: every access happens
63//! inside `handleMessage`, which runs in the `WKScriptMessageHandler` callback
64//! on the main thread (the same argument `readApplicationState` in
65//! `bridge_mobile_device.zig` records). It does not survive relaunch — but
66//! neither did Swift's, and outliving the process would be a *stronger*
67//! promise than the page was given.
68
69const std = ;
70const capabilities = ;
71const bridge_error = ;
72const compat = ;
73
74/// The action names, spelled exactly as the Swift `case` labels spell them.
75/// The conformance ratchet matches these strings against `CraftApp.swift` in
76/// both directions, so a tidier spelling would register as Zig serving an
77/// action the spec does not have.
78pub const A = struct {
79 pub const set_biometric_persistence = "setBiometricPersistence";
80 pub const check_biometric_persistence = "checkBiometricPersistence";
81 pub const clear_biometric_persistence = "clearBiometricPersistence";
82};
83
84/// All three `.result` and `.live`: every Swift path ends in exactly one
85/// `resolveCallback`, every injected `craft.authPersistence` method returns a
86/// promise the page awaits, and nothing here needs a framework, an
87/// entitlement, or a device.
88pub const capability_actions = [_]capabilities.ActionDecl{
89 .{ .name = A.set_biometric_persistence, .reply = .result },
90 .{ .name = A.check_biometric_persistence, .reply = .result },
91 .{ .name = A.clear_biometric_persistence, .reply = .result },
92};
93
94/// Which handler an action selects, or null for one this namespace does not
95/// serve. Split from `handleMessage` so the table-versus-dispatch agreement
96/// is assertable without driving the handlers.
97const Route = enum { set_persistence, check_persistence, clear_persistence };
98
99fn routeFor(action: []const u8) ?Route {
100 if (std.mem.eql(u8, action, A.set_biometric_persistence)) return .set_persistence;
101 if (std.mem.eql(u8, action, A.check_biometric_persistence)) return .check_persistence;
102 if (std.mem.eql(u8, action, A.clear_biometric_persistence)) return .clear_persistence;
103 return null;
104}
105
106/// When the current session stops being valid, in epoch milliseconds — the
107/// unit `expiresAt` is answered in — or null when no session is set.
108///
109/// `f64` rather than `i64` because Swift's arithmetic is all `Double`: a
110/// fractional `duration` is legal JSON, accepted by Swift, and produces a
111/// fractional expiry that `check` must subtract from faithfully. The one
112/// non-finite route into this variable is closed at `expiryFor`.
113///
114/// Module state, not instance state, and `init` must never touch it: the
115/// dispatch chain constructs a fresh bridge per message, and an `init` that
116/// reset this would wipe the session between the `set` that stored it and the
117/// `check` that reads it. Tests reset it explicitly for the same reason.
118var session_expiry_ms: ?f64 = null;
119
120pub const BiometricStoreBridge = struct {
121 allocator: std.mem.Allocator,
122
123 const Self = @This();
124
125 pub fn init(allocator: std.mem.Allocator) Self {
126 return .{ .allocator = allocator };
127 }
128
129 pub fn deinit(_: *Self) void {}
130
131 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
132 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
133 // Exhaustive, so a `Route` added without a handler is a compile error
134 // instead of a silently unreachable action.
135 return switch (route) {
136 .set_persistence => self.setBiometricPersistence(data),
137 // `check` and `clear` carry no payload — the injected JS posts
138 // only the callback id — so `data` is ignored the way
139 // `getAppState` ignores it, rather than validated against a
140 // contract that does not exist.
141 .check_persistence => self.checkBiometricPersistence(),
142 .clear_persistence => self.clearBiometricPersistence(),
143 };
144 }
145
146 /// Start a session (`enabled:true`) or drop it (`enabled:false`).
147 ///
148 /// The state is written only after every fallible step, so a refused
149 /// payload cannot half-apply: an `InvalidParameter` set leaves whatever
150 /// session existed exactly as it was, which is what the error told the
151 /// page.
152 fn setBiometricPersistence(self: *Self, data: []const u8) !void {
153 var parsed = try parsePayload(self.allocator, data);
154 defer parsed.deinit();
155
156 const request = try parseSetRequest(parsed.value);
157
158 var buf: [768]u8 = undefined;
159 const json: []const u8 = if (request.enabled) blk: {
160 const now: f64 = @floatFromInt(compat.milliTimestamp());
161 const expiry = try expiryFor(now, request.duration_seconds);
162 // Reply built *before* the state is committed: `enableReply` is
163 // fallible in type (`bufPrint` can refuse), and a failure after
164 // the write would enable a session while telling the page it
165 // failed — the half-applied outcome this ordering rules out.
166 const reply = try enableReply(&buf, request.duration_seconds, expiry);
167 session_expiry_ms = expiry;
168 break :blk reply;
169 } else blk: {
170 session_expiry_ms = null;
171 break :blk disable_reply;
172 };
173 bridge_error.sendResultToJS(self.allocator, A.set_biometric_persistence, json);
174 }
175
176 /// Is there a live session, and for how much longer.
177 fn checkBiometricPersistence(self: *Self) !void {
178 var buf: [512]u8 = undefined;
179 const now: f64 = @floatFromInt(compat.milliTimestamp());
180 const json = try checkReply(&buf, session_expiry_ms, now);
181 bridge_error.sendResultToJS(self.allocator, A.check_biometric_persistence, json);
182 }
183
184 /// Drop the session. Unconditionally `{"cleared":true}`, as in Swift —
185 /// clearing a session that never existed is not a failure a caller could
186 /// act on, and `clear` is idempotent the way `removeSharedItem` is.
187 fn clearBiometricPersistence(self: *Self) !void {
188 session_expiry_ms = null;
189 bridge_error.sendResultToJS(self.allocator, A.clear_biometric_persistence, clear_reply);
190 }
191};
192
193/// One `set` request, after validation.
194const SetRequest = struct {
195 enabled: bool,
196 /// Seconds, as the page sends it. 300 when absent — the default both
197 /// layers above already apply.
198 duration_seconds: f64 = 300,
199};
200
201/// Parse `d`, distinguishing a bad payload from a failed allocation.
202/// `OutOfMemory` propagates as itself: telling the page INVALID_JSON about
203/// its own good JSON sends whoever debugs it to the wrong side of the bridge.
204fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
205 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
206 error.OutOfMemory => return err,
207 else => return bridge_error.BridgeError.InvalidJSON,
208 };
209}
210
211/// The `enabled`/`duration` pair, or the reason it cannot be used.
212///
213/// Pure, so the host tests can pin every outcome Swift's `if let` collapsed
214/// into one silent fall-through. The field names are pinned on both sides of
215/// the migration: `handleAction` in `CraftApp.swift` parses `d` straight into
216/// `body` and the un-migrated arm reads `body["enabled"]` and
217/// `body["duration"]`.
218///
219/// `duration` is read — and type-checked — on the disable path too, where its
220/// value goes unused. The injected JS never sends it there, so anything
221/// present came from a hand-rolled post, and a request malformed in the field
222/// it chose to send is refused whole rather than half-read.
223fn parseSetRequest(payload: std.json.Value) !SetRequest {
224 const object = switch (payload) {
225 .object => |o| o,
226 else => return bridge_error.BridgeError.InvalidJSON,
227 };
228
229 const enabled_field = object.get("enabled") orelse return bridge_error.BridgeError.MissingData;
230 const enabled = switch (enabled_field) {
231 .bool => |b| b,
232 else => return bridge_error.BridgeError.InvalidParameter,
233 };
234
235 var request = SetRequest{ .enabled = enabled };
236
237 if (object.get("duration")) |duration_field| {
238 request.duration_seconds = switch (duration_field) {
239 .integer => |i| @floatFromInt(i),
240 .float => |f| f,
241 else => return bridge_error.BridgeError.InvalidParameter,
242 };
243 // JSON cannot spell NaN or a literal infinity, but it can spell
244 // `1e999`, which `std.json` parses to +inf rather than refusing. An
245 // infinite duration would put `inf` — not JSON — into the reply.
246 // Swift fares no better: it stores the infinite Date and its
247 // `resolveCallback` then dies inside `JSONSerialization`, which
248 // refuses non-finite numbers. Refusing up front is the honest version
249 // of the same outcome.
250 if (!std.math.isFinite(request.duration_seconds)) return bridge_error.BridgeError.InvalidParameter;
251 }
252
253 return request;
254}
255
256/// `Date(timeIntervalSinceNow: duration)`, in epoch milliseconds.
257///
258/// Fallible because f64 arithmetic can leave the finite range even when
259/// `duration` itself is finite: a duration near 1.8e305 seconds survives
260/// `parseSetRequest`'s check and overflows in the `* 1000.0` here. An
261/// infinite expiry would render as `inf` and break every later reply, so
262/// "a duration no clock can represent" is `InvalidParameter`.
263fn expiryFor(now_ms: f64, duration_seconds: f64) !f64 {
264 const expiry = now_ms + duration_seconds * 1000.0;
265 if (!std.math.isFinite(expiry)) return bridge_error.BridgeError.InvalidParameter;
266 return expiry;
267}
268
269/// `{"enabled":true,"duration":…,"expiresAt":…}` — the enable reply.
270///
271/// `bufPrint`, not the escaping builder `bridge_mobile_storage.zig` needs:
272/// every interpolated value is a number this module computed, and `{d}` on a
273/// finite f64 always renders plain decimal — never scientific, never `inf` —
274/// which is exactly the JSON number grammar. Whole values print without a
275/// trailing `.0`, so `"duration":300` reaches the page as the integer it
276/// sent; JavaScript does not distinguish.
277fn enableReply(buf: []u8, duration_seconds: f64, expires_at_ms: f64) ![]const u8 {
278 return std.fmt.bufPrint(
279 buf,
280 "",
281 .{ duration_seconds, expires_at_ms },
282 );
283}
284
285/// The disable reply. No `duration` and no `expiresAt`: Swift's disable arm
286/// answers the one field, and inventing the others would describe a session
287/// that no longer exists.
288const disable_reply = "{\"enabled\":false}";
289
290/// Doubles as the expired-session reply: Swift renders "never set" and
291/// "expired" identically (`isValid:false, remainingSeconds:0`), and the page
292/// cannot tell them apart by design.
293const no_session_reply = "{\"isValid\":false,\"remainingSeconds\":0}";
294
295/// The clear reply. Unconditional, as in Swift.
296const clear_reply = "{\"cleared\":true}";
297
298/// `{"isValid":…,"remainingSeconds":…}` — the check reply.
299///
300/// Pure in `(expiry, now)` so every branch is a host test with a chosen
301/// clock. Three Swift behaviours are pinned here: an expired-but-set timer is
302/// `false`/`0` and never a negative remainder; `Date() < expiry` is strict,
303/// so the exact expiry instant is already invalid; and `remainingSeconds` is
304/// *seconds* while the stored expiry (and `expiresAt`) are milliseconds — the
305/// divide by 1000 is the contract, not a tidy-up.
306fn checkReply(buf: []u8, expiry_ms: ?f64, now_ms: f64) ![]const u8 {
307 const expiry = expiry_ms orelse return no_session_reply;
308 if (!(now_ms < expiry)) return no_session_reply;
309 return std.fmt.bufPrint(
310 buf,
311 "",
312 .{(expiry - now_ms) / 1000.0},
313 );
314}
315
316const testing = std.testing;
317
318test "every declared action is one the dispatcher routes" {
319 // The failure the mechanism exists to prevent: a name in the table that
320 // `handleMessage` has never heard of, which the manifest then promises.
321 for (capability_actions) |decl| {
322 if (routeFor(decl.name) == null) {
323 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
324 return error.DeclaredActionDoesNotRoute;
325 }
326 }
327}
328
329test "every route the dispatcher has is a declared action" {
330 // The other direction. `handleMessage` switches exhaustively over
331 // `Route`, so a route without a handler cannot compile; this catches a
332 // route with a handler and no declaration. Each declaration must claim a
333 // *distinct* route — two rows naming one action would leave another
334 // route undeclared while the count still matched.
335 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
336 for (capability_actions) |decl| {
337 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
338 const slot = @backingInt(route);
339 if (claimed[slot]) {
340 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
341 return error.TwoDeclarationsShareARoute;
342 }
343 claimed[slot] = true;
344 }
345 for (claimed, 0..) |taken, slot| {
346 if (!taken) {
347 std.debug.print(
348 "route {s} has a handler but no capability_actions row\n",
349 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
350 );
351 return error.RouteNotDeclared;
352 }
353 }
354}
355
356test "the action names are the ones the Swift dispatcher answers" {
357 // The wire contract, spelled out: a rename here is invisible until a page
358 // stops being answered.
359 try testing.expectEqualStrings("setBiometricPersistence", A.set_biometric_persistence);
360 try testing.expectEqualStrings("checkBiometricPersistence", A.check_biometric_persistence);
361 try testing.expectEqualStrings("clearBiometricPersistence", A.clear_biometric_persistence);
362}
363
364test "all three actions resolve a promise and none is declared beyond its means" {
365 // `.live` is honest here precisely because nothing native is involved:
366 // there is no framework to be missing and no entitlement to lack.
367 for (capability_actions) |decl| {
368 try testing.expectEqual(capabilities.Reply.result, decl.reply);
369 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
370 }
371}
372
373test "an action the namespace does not serve is reported, not ignored" {
374 var bridge = BiometricStoreBridge.init(testing.allocator);
375 defer bridge.deinit();
376
377 try testing.expectError(
378 bridge_error.BridgeError.UnknownAction,
379 bridge.handleMessage("noSuchAction", "{}"),
380 );
381 // Casing is how a real typo arrives.
382 try testing.expectError(
383 bridge_error.BridgeError.UnknownAction,
384 bridge.handleMessage("setbiometricpersistence", "{}"),
385 );
386 // The desktop biometric bridge's vocabulary, which is a different
387 // namespace this module must not shadow.
388 try testing.expectError(
389 bridge_error.BridgeError.UnknownAction,
390 bridge.handleMessage("evaluate", "{}"),
391 );
392 try testing.expectError(
393 bridge_error.BridgeError.UnknownAction,
394 bridge.handleMessage("getBiometryType", "{}"),
395 );
396}
397
398test "a malformed payload is reported as bad JSON, not as a missing field" {
399 var bridge = BiometricStoreBridge.init(testing.allocator);
400 defer bridge.deinit();
401
402 try testing.expectError(
403 bridge_error.BridgeError.InvalidJSON,
404 bridge.handleMessage(A.set_biometric_persistence, "{not json"),
405 );
406}
407
408/// Parse a literal payload straight through to a `SetRequest`. Unlike
409/// storage's `Request`, this one holds no slices into the parsed tree, so it
410/// can be returned past the parse's deinit.
411fn parseSetFromLiteral(json: []const u8) !SetRequest {
412 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
413 defer parsed.deinit();
414 return parseSetRequest(parsed.value);
415}
416
417test "the two field names the page sends are the two that are read" {
418 const request = try parseSetFromLiteral("{\"enabled\":true,\"duration\":300}");
419 try testing.expect(request.enabled);
420 try testing.expectEqual(@as(f64, 300), request.duration_seconds);
421}
422
423test "a missing enabled is refused, not left hanging" {
424 // Swift's `if let enabled = body["enabled"] as? Bool` has no else, so
425 // this exact payload leaves the page's promise pending forever. Every
426 // path here answers.
427 try testing.expectError(bridge_error.BridgeError.MissingData, parseSetFromLiteral("{}"));
428 try testing.expectError(
429 bridge_error.BridgeError.MissingData,
430 parseSetFromLiteral("{\"duration\":300}"),
431 );
432}
433
434test "a non-bool enabled is refused, not coerced" {
435 // JavaScript truthiness stops at the bridge: 1 and "true" are not `true`,
436 // and guessing which way the page meant them would enable or disable a
437 // session it did not ask about.
438 try testing.expectError(
439 bridge_error.BridgeError.InvalidParameter,
440 parseSetFromLiteral("{\"enabled\":1}"),
441 );
442 try testing.expectError(
443 bridge_error.BridgeError.InvalidParameter,
444 parseSetFromLiteral("{\"enabled\":\"true\"}"),
445 );
446 try testing.expectError(
447 bridge_error.BridgeError.InvalidParameter,
448 parseSetFromLiteral("{\"enabled\":null}"),
449 );
450}
451
452test "an absent duration defaults to 300, matching both layers above" {
453 const enable = try parseSetFromLiteral("{\"enabled\":true}");
454 try testing.expectEqual(@as(f64, 300), enable.duration_seconds);
455 // The disable path is where absence is the *normal* case: the injected
456 // JS `disable()` sends no duration field at all.
457 const disable = try parseSetFromLiteral("{\"enabled\":false}");
458 try testing.expect(!disable.enabled);
459 try testing.expectEqual(@as(f64, 300), disable.duration_seconds);
460}
461
462test "a present duration is taken at its word, zero and negative included" {
463 // The injected JS maps a falsy duration to 300 *before posting*, so a 0
464 // arriving here was sent deliberately by a hand-rolled post — and Swift
465 // honours it as an instantly-expired session. Re-defaulting it would
466 // silently alter a field the page sent.
467 try testing.expectEqual(@as(f64, 0), (try parseSetFromLiteral("{\"enabled\":true,\"duration\":0}")).duration_seconds);
468 try testing.expectEqual(@as(f64, -60), (try parseSetFromLiteral("{\"enabled\":true,\"duration\":-60}")).duration_seconds);
469 try testing.expectEqual(@as(f64, 2.5), (try parseSetFromLiteral("{\"enabled\":true,\"duration\":2.5}")).duration_seconds);
470}
471
472test "a non-numeric duration is refused, not silently replaced with 300" {
473 // Swift's `as? Double ?? 300` rewrites `"duration":"300"` into 300 and
474 // reports success — the dropped-payload-field bug, verbatim.
475 try testing.expectError(
476 bridge_error.BridgeError.InvalidParameter,
477 parseSetFromLiteral("{\"enabled\":true,\"duration\":\"300\"}"),
478 );
479 try testing.expectError(
480 bridge_error.BridgeError.InvalidParameter,
481 parseSetFromLiteral("{\"enabled\":true,\"duration\":{}}"),
482 );
483 // The disable path validates too: the value is unused there, but a
484 // request malformed in the field it chose to send is refused whole.
485 try testing.expectError(
486 bridge_error.BridgeError.InvalidParameter,
487 parseSetFromLiteral("{\"enabled\":false,\"duration\":[]}"),
488 );
489}
490
491test "a duration JSON can spell but no clock can hold is refused" {
492 // `1e999` is legal JSON and `std.json` parses it to +inf rather than
493 // refusing. Left through, it would print `inf` into the reply — which is
494 // not JSON — and Swift's own reply serialisation refuses non-finite
495 // numbers anyway.
496 try testing.expectError(
497 bridge_error.BridgeError.InvalidParameter,
498 parseSetFromLiteral("{\"enabled\":true,\"duration\":1e999}"),
499 );
500}
501
502test "a payload that is not an object is bad JSON, not a missing field" {
503 try testing.expectError(bridge_error.BridgeError.InvalidJSON, parseSetFromLiteral("[]"));
504 try testing.expectError(bridge_error.BridgeError.InvalidJSON, parseSetFromLiteral("true"));
505}
506
507test "the expiry is now plus the duration, in milliseconds" {
508 try testing.expectEqual(@as(f64, 1301000), try expiryFor(1001000, 300));
509 // Fractional durations survive: 1.5005 s is 1500.5 ms.
510 try testing.expectEqual(@as(f64, 1001500.5), try expiryFor(1000000, 1.5005));
511 // A negative duration is a legal, instantly-expired session, as in
512 // Swift's `Date(timeIntervalSinceNow:)`.
513 try testing.expectEqual(@as(f64, 940000), try expiryFor(1000000, -60));
514}
515
516test "a finite duration whose expiry is not finite is refused" {
517 // floatMax survives `parseSetRequest`'s finiteness check; the `* 1000.0`
518 // here is what overflows. Letting it through would store `inf` and break
519 // every later `check`.
520 try testing.expectError(
521 bridge_error.BridgeError.InvalidParameter,
522 expiryFor(0, std.math.floatMax(f64)),
523 );
524}
525
526test "the enable reply is the Swift dictionary verbatim, expiresAt in milliseconds" {
527 var buf: [768]u8 = undefined;
528 try testing.expectEqualStrings(
529 "{\"enabled\":true,\"duration\":300,\"expiresAt\":1301000}",
530 try enableReply(&buf, 300, 1301000),
531 );
532}
533
534test "a fractional duration is echoed back, not rounded" {
535 var buf: [768]u8 = undefined;
536 const json = try enableReply(&buf, 2.5, 1756480000002.5);
537 try testing.expectEqualStrings(
538 "{\"enabled\":true,\"duration\":2.5,\"expiresAt\":1756480000002.5}",
539 json,
540 );
541
542 // And it is JSON whose numbers survive as numbers.
543 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
544 defer parsed.deinit();
545 try testing.expect(parsed.value.object.get("enabled").?.bool);
546 try testing.expectEqual(@as(f64, 2.5), parsed.value.object.get("duration").?.float);
547 try testing.expectEqual(@as(f64, 1756480000002.5), parsed.value.object.get("expiresAt").?.float);
548}
549
550test "a live session answers true and the remainder in seconds" {
551 var buf: [512]u8 = undefined;
552 // The stored expiry and `now` are milliseconds; the reply divides. The
553 // units differ from `expiresAt` on purpose, because they differ in Swift.
554 try testing.expectEqualStrings(
555 "{\"isValid\":true,\"remainingSeconds\":300}",
556 try checkReply(&buf, 1301000, 1001000),
557 );
558 // Sub-second remainders keep their fraction.
559 try testing.expectEqualStrings(
560 "{\"isValid\":true,\"remainingSeconds\":1.5}",
561 try checkReply(&buf, 1001500, 1000000),
562 );
563}
564
565test "no session, an expired session and the exact expiry instant all answer the same" {
566 var buf: [512]u8 = undefined;
567 // Never set.
568 try testing.expectEqualStrings(no_session_reply, try checkReply(&buf, null, 1000000));
569 // Expired: zero, never a negative remainder — Swift's ternary pins that.
570 try testing.expectEqualStrings(no_session_reply, try checkReply(&buf, 999000, 1000000));
571 // Swift's `Date() < expiry` is strict: at the boundary, the session is
572 // already over.
573 try testing.expectEqualStrings(no_session_reply, try checkReply(&buf, 1000000, 1000000));
574}
575
576test "the constant replies are the fields the SDK type declares, and nothing more" {
577 // `craft.d.ts`: disable() -> {enabled}, check() -> {isValid,
578 // remainingSeconds}, clear() -> {cleared}. The field counts matter too:
579 // extra fields would be harmless to today's stringify-only consumers and
580 // a lie to the next one.
581 const disable = try std.json.parseFromSlice(std.json.Value, testing.allocator, disable_reply, .{});
582 defer disable.deinit();
583 try testing.expect(!disable.value.object.get("enabled").?.bool);
584 try testing.expectEqual(@as(usize, 1), disable.value.object.count());
585
586 const none = try std.json.parseFromSlice(std.json.Value, testing.allocator, no_session_reply, .{});
587 defer none.deinit();
588 try testing.expect(!none.value.object.get("isValid").?.bool);
589 try testing.expectEqual(@as(i64, 0), none.value.object.get("remainingSeconds").?.integer);
590 try testing.expectEqual(@as(usize, 2), none.value.object.count());
591
592 const cleared = try std.json.parseFromSlice(std.json.Value, testing.allocator, clear_reply, .{});
593 defer cleared.deinit();
594 try testing.expect(cleared.value.object.get("cleared").?.bool);
595 try testing.expectEqual(@as(usize, 1), cleared.value.object.count());
596}
597
598test "enable stores the expiry the reply names; disable and clear drop it" {
599 session_expiry_ms = null;
600 defer session_expiry_ms = null;
601
602 var bridge = BiometricStoreBridge.init(testing.allocator);
603 defer bridge.deinit();
604
605 // The reply itself goes to a webview this process does not have —
606 // `sendResultToJS` swallows that, as `bridge_mobile_device.zig`'s log
607 // test already relies on — so the observable half on a host is the state.
608 const before = compat.milliTimestamp();
609 try bridge.handleMessage(A.set_biometric_persistence, "{\"enabled\":true,\"duration\":300}");
610 const after = compat.milliTimestamp();
611
612 const expiry = session_expiry_ms orelse return error.SessionNotStored;
613 try testing.expect(expiry >= @as(f64, @floatFromInt(before)) + 300000);
614 try testing.expect(expiry <= @as(f64, @floatFromInt(after)) + 300000);
615
616 try bridge.handleMessage(A.set_biometric_persistence, "{\"enabled\":false}");
617 try testing.expect(session_expiry_ms == null);
618
619 try bridge.handleMessage(A.set_biometric_persistence, "{\"enabled\":true}");
620 try testing.expect(session_expiry_ms != null);
621 try bridge.handleMessage(A.clear_biometric_persistence, "{}");
622 try testing.expect(session_expiry_ms == null);
623}
624
625test "a refused set leaves the session exactly as it was" {
626 // The half-applied failure mode: state written before validation. The
627 // page was told InvalidParameter, so nothing may have changed.
628 session_expiry_ms = 123456789;
629 defer session_expiry_ms = null;
630
631 var bridge = BiometricStoreBridge.init(testing.allocator);
632 defer bridge.deinit();
633
634 try testing.expectError(
635 bridge_error.BridgeError.InvalidParameter,
636 bridge.handleMessage(A.set_biometric_persistence, "{\"enabled\":\"yes\",\"duration\":300}"),
637 );
638 try testing.expectEqual(@as(?f64, 123456789), session_expiry_ms);
639
640 try testing.expectError(
641 bridge_error.BridgeError.MissingData,
642 bridge.handleMessage(A.set_biometric_persistence, "{\"duration\":300}"),
643 );
644 try testing.expectEqual(@as(?f64, 123456789), session_expiry_ms);
645}
646
647test "check reports an expired session without clearing it" {
648 // Swift leaves the stale Date in place; `check` is a reader here too.
649 // Only `clear` and disable remove it, and a later `set` overwrites it.
650 session_expiry_ms = 5; // 1970, long expired
651 defer session_expiry_ms = null;
652
653 var bridge = BiometricStoreBridge.init(testing.allocator);
654 defer bridge.deinit();
655
656 try bridge.handleMessage(A.check_biometric_persistence, "{}");
657 try testing.expectEqual(@as(?f64, 5), session_expiry_ms);
658}
packages/zig/src/bridge_mobile_misc.zigadded+454-0
Changes to packages/zig/src/bridge_mobile_misc.zig
@@ -0,0 +1,454 @@
1//! The `mobile` namespace's flashlight action — and the recorded decision that
2//! the other three actions scoped to this module (`getInitialURL`,
3//! `registerDeepLinkHandler`, `takeScreenshot`) stay *unserved*, so that
4//! `ios_dispatch.route` falls through to the Swift shim that answers them
5//! correctly today. See `deliberately_unserved`.
6//!
7//! ## Why `setFlashlight` is the only arm
8//!
9//! It is the only one of the four with no `CraftConfig` gate in front of it:
10//! the Swift dispatcher calls `setFlashlight(enabled:)` unconditionally, and
11//! the injected capabilities blob hardcodes `flashlight: true`. Every step is
12//! a synchronous AVFoundation call — no completion handler, no `ios_async`
13//! ticket — so the whole exchange happens inside the dispatch frame that holds
14//! the request id, on the main thread WebKit delivered it on.
15//!
16//! The other three founder on one missing channel: their Swift arms are
17//! conditional on `CraftConfig` flags (`enableDeepLinks`,
18//! `enableScreenCapture`) that exist only as Swift properties. Nothing carries
19//! them to Zig — no export, no shared defaults, nothing to grep — so a Zig
20//! handler cannot know whether the app author enabled the feature. Each action
21//! adds its own reason on top:
22//!
23//! - **`getInitialURL`** cannot be answered from Zig at all. The value lives
24//! in `DeepLinkManager.shared.initialURL` — a private property of a
25//! pure-Swift class that derives from nothing and declares no `@objc`, so
26//! `objc_getClass("DeepLinkManager")` cannot find it and neither `shared`
27//! nor `getInitialURL()` has an ObjC entry point. It is fed by SwiftUI's
28//! `.onOpenURL`, which Zig has no path to, and the launch URL is not
29//! re-derivable afterwards (not in the environment, not in NSUserDefaults).
30//! - **`registerDeepLinkHandler`** does nothing but acknowledge: Swift
31//! replies the bare boolean `true` iff `config.enableDeepLinks`; the real
32//! registration is the page's own `addEventListener('craftDeepLink', …)`.
33//! An unconditional `true` from Zig would report "registered" in apps where
34//! the event can never fire. No JS anywhere in the repo posts the action —
35//! the injected script, `test-bridges.html`, `craft-bridge.js` and the TS
36//! SDK are all silent — so there is also nothing to migrate *for*.
37//! - **`takeScreenshot`**'s capture is fully doable from Zig
38//! (`UIGraphicsBeginImageContextWithOptions` and friends, then
39//! `UIImagePNGRepresentation`), but `enableScreenCapture` defaults to
40//! *false* and Swift's arm skips the handler entirely when it is off.
41//! Serving it unconditionally would switch on a capability the app author
42//! turned off — fabricated authorization, the exact class of bug this
43//! migration exists to remove. It stays with the shim until a config
44//! channel exists.
45//!
46//! ## Why unserved rather than `.unavailable`
47//!
48//! `ios_dispatch.route` hands an action to `CraftSwiftShim` only when every
49//! module answers `UnknownAction`. A declared-`.unavailable` action
50//! *dispatches and refuses* — `bridge_mobile_display.lockOrientation` is the
51//! precedent — so declaring these three would take them away from the shim,
52//! replacing answers that are correct today with rejections. In a Zig-only
53//! build with no shim, the fall-through degrades to an `UnknownAction` error
54//! at the page, which is still the honest outcome: absent the config, craft
55//! does not know whether these features are enabled, and an error a page can
56//! see beats a guess it cannot.
57//!
58//! ## Build note
59//!
60//! A generated app autolinks AVFoundation (`CraftApp.swift` does
61//! `import AVFoundation`). The zig-slice fixture
62//! (`packages/ios/fixtures/zig-slice/build-and-run.sh`) links only
63//! UIKit/WebKit/Foundation/Security, so there `objc_getClass("AVCaptureDevice")`
64//! returns null until `-framework AVFoundation` is added — which the null
65//! check reports as "no flashlight", the same rejection Swift's guard gives a
66//! simulator. Honest either way, but the fixture needs the flag before the
67//! torch can actually light.
68
69const std = ;
70const builtin = ;
71const capabilities = ;
72const bridge_error = ;
73const objc_runtime = ;
74
75const objc = objc_runtime.objc;
76const BridgeError = bridge_error.BridgeError;
77
78/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
79///
80/// `objc_runtime.objc` is an empty struct off Darwin, and a `callconv(.c)`
81/// fn-pointer *type* is analysed even when a comptime platform guard makes the
82/// code around it unreachable, so naming `objc.id` there would break the host
83/// build. A single optional pointer, never `?objc.id`: a double optional is
84/// illegal in a `callconv(.c)` signature.
85const Id = ?*anyopaque;
86
87/// `AVCaptureTorchMode`, from `AVCaptureDevice.h`. An `NSInteger`, hence
88/// `c_long` — a narrower type would pass the right bits for 0 and 1 by luck
89/// and break the day a new mode appears.
90const AVCaptureTorchModeOff: c_long = 0;
91const AVCaptureTorchModeOn: c_long = 1;
92
93/// The action name, spelled exactly as the Swift `case` label spells it.
94///
95/// `test/ios_conformance_test.zig` reads this block as "actions Zig serves".
96/// The three unserved actions this module documents must therefore *not*
97/// appear here: listing one would tell the ratchet it migrated while the shim
98/// is still the thing answering it.
99pub const A = struct {
100 pub const set_flashlight = "setFlashlight";
101};
102
103/// The actions this module was scoped to and, on purpose, does not serve.
104///
105/// Recorded as data rather than prose so a test can hold the two properties
106/// the decision rests on: none of them is declared, and each still falls out
107/// of `handleMessage` as `UnknownAction` — the one return `ios_dispatch.route`
108/// converts into a Swift-shim hand-off. Whoever migrates one for real moves
109/// its name from this list into `A` and updates the module comment.
110pub const deliberately_unserved = [_][]const u8{
111 "getInitialURL",
112 "registerDeepLinkHandler",
113 "takeScreenshot",
114};
115
116/// `.result`: every Swift path terminates in `resolveCallback(…, true)` or a
117/// rejection, and the injected `setFlashlight` returns a promise the page can
118/// await. `.live`: the no-torch outcome (simulator, torchless hardware, an
119/// app that dropped AVFoundation) is a per-call rejection — a property of the
120/// device the call found, not of the action.
121pub const capability_actions = [_]capabilities.ActionDecl{
122 .{ .name = A.set_flashlight, .reply = .result },
123};
124
125/// Swift resolves with `resolveCallback(callbackId, result: true)`, serialized
126/// `.fragmentsAllowed`, so the page receives the bare JSON boolean `true` —
127/// not `{"success":true}`. No caller in the repo reads the resolved value
128/// (`craft.d.ts` even types the method `void`), but the shape is the contract
129/// and inventing an object here would change it.
130const success_reply = "true";
131
132pub const MiscBridge = struct {
133 allocator: std.mem.Allocator,
134
135 const Self = @This();
136
137 pub fn init(allocator: std.mem.Allocator) Self {
138 return .{ .allocator = allocator };
139 }
140
141 pub fn deinit(_: *Self) void {}
142
143 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
144 if (std.mem.eql(u8, action, A.set_flashlight)) {
145 try self.setFlashlight(data);
146 } else {
147 // `getInitialURL`, `registerDeepLinkHandler` and `takeScreenshot`
148 // land here on purpose: `UnknownAction` is what routes an action
149 // onward to the Swift shim. See the module comment.
150 return BridgeError.UnknownAction;
151 }
152 }
153
154 /// Turn the torch on or off, and say so with Swift's bare `true`.
155 fn setFlashlight(self: *Self, data: []const u8) !void {
156 var parsed = try parsePayload(self.allocator, data);
157 defer parsed.deinit();
158
159 const enabled = try parseEnabled(parsed.value);
160
161 try setTorch(self.allocator, enabled);
162
163 bridge_error.sendResultToJS(self.allocator, A.set_flashlight, success_reply);
164 }
165};
166
167/// Parse `d`, distinguishing a bad payload from a failed allocation.
168///
169/// `OutOfMemory` propagates as itself: telling the page INVALID_JSON about its
170/// own perfectly good JSON sends whoever debugs it to the wrong side of the
171/// bridge.
172fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
173 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
174 error.OutOfMemory => return err,
175 else => return BridgeError.InvalidJSON,
176 };
177}
178
179/// The `enabled` the page sent, or the reason it cannot be used. Pure, so the
180/// host tests pin every outcome that Swift's `as? Bool` collapsed into one
181/// silent fall-through.
182///
183/// The field name is the one the injected JS posts beside `action` and
184/// `callbackId` — `{action: 'setFlashlight', enabled: enabled, …}` — and the
185/// un-migrated shim reads `body["enabled"]`; both sides of the migration have
186/// to read the same payload.
187///
188/// Swift's arm is `if let enabled = body["enabled"] as? Bool` with no `else`:
189/// a missing or mistyped field replies nothing and the promise hangs for the
190/// full request timeout. That is deliberately not carried across (the storage
191/// module states the family policy); every path here ends in a value or an
192/// error. One divergence rides along: `as? Bool` also lets the NSNumbers 0
193/// and 1 through, so `{"enabled":1}` flips the torch via the shim today and
194/// is `InvalidParameter` here — refused rather than coerced, as this family
195/// refuses everywhere, and the typed surface
196/// (`craft.d.ts: setFlashlight(enabled: boolean)`) never produces it.
197fn parseEnabled(payload: std.json.Value) !bool {
198 const object = switch (payload) {
199 .object => |o| o,
200 else => return BridgeError.InvalidJSON,
201 };
202 const field = object.get("enabled") orelse return BridgeError.MissingData;
203 return switch (field) {
204 .bool => |b| b,
205 else => BridgeError.InvalidParameter,
206 };
207}
208
209/// The raw `AVCaptureTorchMode` for a request. Pure, so the one value that
210/// reaches the hardware is pinned by a host test rather than by reading the
211/// header again — a swap would invert every page's flashlight with no error
212/// anywhere.
213fn torchModeFor(enabled: bool) c_long {
214 return if (enabled) AVCaptureTorchModeOn else AVCaptureTorchModeOff;
215}
216
217/// `AVCaptureDevice.default(for: .video)` → `hasTorch` → lock, set, unlock.
218///
219/// Error mapping is lossy the same way the storage module's OSStatus mapping
220/// is — `sendErrorToJS` carries an enum, not text — so Swift's two rejection
221/// strings become the nearest `BridgeError` and the specifics go to the log:
222/// every flavour of "Flashlight not available" (no framework, no device, no
223/// torch) is `NotFound`, and a refused `lockForConfiguration:` (Swift's
224/// `catch`, rejected with `error.localizedDescription`) is `NativeCallFailed`.
225fn setTorch(allocator: std.mem.Allocator, enabled: bool) !void {
226 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
227
228 const AVCaptureDevice = objc.objc_getClass("AVCaptureDevice") orelse {
229 // AVFoundation is not in the process — see the module's build note.
230 // No framework, no torch; the same "not available" answer Swift's
231 // guard would give, reached one step earlier.
232 std.log.warn("setFlashlight: AVCaptureDevice class not found; is AVFoundation linked?", .{});
233 return BridgeError.NotFound;
234 };
235
236 // AVMediaTypeVideo is the FourCC string "vide". The literal, not the
237 // extern constant — same trade as `bridge_continuity_camera.zig`: the
238 // constant would put AVFoundation on every consumer's *link* line, and
239 // the value is fixed by decades of QuickTime FourCC compatibility.
240 const media_type = (try objc.createNSString("vide", allocator)) orelse
241 return BridgeError.NativeCallFailed;
242
243 const sel_default = objc.sel_registerName("defaultDeviceWithMediaType:") orelse return error.SelectorNotFound;
244 const device = objc.msgSendId1(AVCaptureDevice, sel_default, media_type) orelse {
245 // The simulator's answer, and a camera-less device's. Swift's guard
246 // takes the same exit: reject, never pretend the torch moved.
247 std.log.info("setFlashlight: no default video capture device (simulator, or no camera)", .{});
248 return BridgeError.NotFound;
249 };
250
251 const sel_has_torch = objc.sel_registerName("hasTorch") orelse return error.SelectorNotFound;
252 if (!objc.msgSendBool(device, sel_has_torch)) {
253 std.log.info("setFlashlight: capture device has no torch", .{});
254 return BridgeError.NotFound;
255 }
256
257 // All three selectors are registered *before* the lock is taken, so no
258 // error path exists between `lockForConfiguration:` and
259 // `unlockForConfiguration` — an early return in that window would leave
260 // the device configuration locked for the rest of the process.
261 const sel_lock = objc.sel_registerName("lockForConfiguration:") orelse return error.SelectorNotFound;
262 const sel_set_mode = objc.sel_registerName("setTorchMode:") orelse return error.SelectorNotFound;
263 const sel_unlock = objc.sel_registerName("unlockForConfiguration") orelse return error.SelectorNotFound;
264
265 // `- (BOOL)lockForConfiguration:(NSError **)outError`, with the out-param
266 // left null: the BOOL already separates locked from not, the NSError
267 // would arrive autoreleased with nothing to do but be logged, and Swift's
268 // `catch` keeps only its message too. The usual cause of a refusal is
269 // another capture session holding the device.
270 const LockFn = *const fn (Id, objc.SEL, ?*Id) callconv(.c) bool;
271 const lock: LockFn = @ptrCast(&objc.objc_msgSend);
272 if (!lock(device, sel_lock, null)) {
273 std.log.warn("setFlashlight: lockForConfiguration: refused", .{});
274 return BridgeError.NativeCallFailed;
275 }
276
277 objc.msgSendVoid1(device, sel_set_mode, torchModeFor(enabled));
278 objc.msgSend(device, sel_unlock);
279}
280
281// =============================================================================
282// Tests
283//
284// Host-only, and there is deliberately no live AVFoundation test. On a Mac
285// with a Continuity Camera iPhone paired, `defaultDeviceWithMediaType:` can
286// return a device whose `hasTorch` is YES — at which point a "test" would
287// fire the torch on somebody's actual phone. Every honest outcome on a host
288// is either environment-dependent (class linked or not, camera present or
289// not) or a side effect on hardware craft does not own, and a test whose pass
290// condition varies by desk is a flake, not a gate. The decisions that fit in
291// a host test — routing, payload validation, the mode values, the reply
292// shape — are pure functions above, and are pinned below.
293// =============================================================================
294
295const testing = std.testing;
296
297test "the declared action is the one the handler serves" {
298 try testing.expectEqual(@as(usize, 1), capability_actions.len);
299 try testing.expectEqualStrings(A.set_flashlight, capability_actions[0].name);
300 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
301 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status);
302}
303
304test "the action name matches the Swift case label exactly" {
305 // The conformance ratchet compares this string against the `case "…":`
306 // labels in `CraftApp.swift`, in both directions; a prettier spelling
307 // would register as Zig serving an action the spec does not have.
308 try testing.expectEqualStrings("setFlashlight", A.set_flashlight);
309}
310
311test "every declared action dispatches to something" {
312 // `{}` has no `enabled`, so validation fails *before* any AVFoundation
313 // call — which is what makes this safe on a host with a real camera. What
314 // it rules out is a name in the table `handleMessage` never compares
315 // against.
316 var bridge = MiscBridge.init(testing.allocator);
317 defer bridge.deinit();
318
319 for (capability_actions) |decl| {
320 try testing.expectError(
321 BridgeError.MissingData,
322 bridge.handleMessage(decl.name, "{}"),
323 );
324 }
325}
326
327test "the unserved actions stay unserved, which is what routes them to the shim" {
328 var bridge = MiscBridge.init(testing.allocator);
329 defer bridge.deinit();
330
331 for (deliberately_unserved) |name| {
332 // `UnknownAction` is the one return `ios_dispatch.route` turns into a
333 // Swift-shim hand-off. Anything else — a result, `.unavailable`'s
334 // refusal, any other error — would take the action away from the arm
335 // that answers it correctly today.
336 try testing.expectError(
337 BridgeError.UnknownAction,
338 bridge.handleMessage(name, "{}"),
339 );
340
341 // And none of them is declared: a capability row for an action the
342 // shim serves would be this module claiming another component's work,
343 // and a name in `A` would tell the conformance ratchet it migrated.
344 for (capability_actions) |decl| {
345 try testing.expect(!std.mem.eql(u8, decl.name, name));
346 }
347 }
348}
349
350test "an action the namespace does not serve is reported, not ignored" {
351 var bridge = MiscBridge.init(testing.allocator);
352 defer bridge.deinit();
353
354 try testing.expectError(
355 BridgeError.UnknownAction,
356 bridge.handleMessage("noSuchAction", "{}"),
357 );
358 // `craft.d.ts` declares `toggleFlashlight` and `test-bridges.html` calls
359 // it, but no injected JS defines it and no dispatcher case answers it — a
360 // pre-existing spec gap, not an action to invent here.
361 try testing.expectError(
362 BridgeError.UnknownAction,
363 bridge.handleMessage("toggleFlashlight", "{\"enabled\":true}"),
364 );
365 // Casing is how a real typo arrives.
366 try testing.expectError(
367 BridgeError.UnknownAction,
368 bridge.handleMessage("setflashlight", "{\"enabled\":true}"),
369 );
370}
371
372test "a malformed payload is reported as bad JSON, not as a missing field" {
373 var bridge = MiscBridge.init(testing.allocator);
374 defer bridge.deinit();
375
376 try testing.expectError(
377 BridgeError.InvalidJSON,
378 bridge.handleMessage(A.set_flashlight, "{not json"),
379 );
380}
381
382/// Parse a literal payload and check the extracted `enabled` — the slices in
383/// a `std.json.Value` point into the `Parsed`, so it stays alive around the
384/// check.
385fn expectEnabled(json: []const u8, expected: bool) !void {
386 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
387 defer parsed.deinit();
388 try testing.expectEqual(expected, try parseEnabled(parsed.value));
389}
390
391fn expectEnabledError(json: []const u8, expected: anyerror) !void {
392 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
393 defer parsed.deinit();
394 try testing.expectError(expected, parseEnabled(parsed.value));
395}
396
397test "the field name the page sends is the field that is read" {
398 try expectEnabled("{\"enabled\":true}", true);
399 try expectEnabled("{\"enabled\":false}", false);
400}
401
402test "a missing enabled is refused rather than defaulted" {
403 // Defaulting to `false` would report "torch off" as the success of a
404 // request that asked for nothing; Swift replies nothing at all and the
405 // promise hangs. Both silent shapes are the ones this family removes.
406 try expectEnabledError("{}", BridgeError.MissingData);
407 // Field names are case-sensitive on the wire; a near-miss is absent.
408 try expectEnabledError("{\"Enabled\":true}", BridgeError.MissingData);
409}
410
411test "a non-boolean enabled is refused, not coerced" {
412 // The one deliberate divergence from Swift, documented at `parseEnabled`:
413 // `as? Bool` lets the NSNumbers 0 and 1 through, so `{"enabled":1}` flips
414 // the torch via the shim today. Coercion is refused everywhere in this
415 // family, and the typed JS surface never produces it.
416 try expectEnabledError("{\"enabled\":1}", BridgeError.InvalidParameter);
417 try expectEnabledError("{\"enabled\":0}", BridgeError.InvalidParameter);
418 try expectEnabledError("{\"enabled\":\"true\"}", BridgeError.InvalidParameter);
419 try expectEnabledError("{\"enabled\":null}", BridgeError.InvalidParameter);
420}
421
422test "a payload that is not an object is bad JSON, not a missing field" {
423 try expectEnabledError("[]", BridgeError.InvalidJSON);
424 try expectEnabledError("true", BridgeError.InvalidJSON);
425}
426
427test "the torch modes are the header's values" {
428 try testing.expectEqual(@as(c_long, 1), torchModeFor(true));
429 try testing.expectEqual(@as(c_long, 0), torchModeFor(false));
430}
431
432test "the success reply is Swift's bare boolean, and it parses" {
433 // `.fragmentsAllowed`: the page's promise resolves with `true` itself,
434 // not an object wrapping it.
435 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, success_reply, .{});
436 defer parsed.deinit();
437 try testing.expect(parsed.value == .bool);
438 try testing.expect(parsed.value.bool);
439}
440
441test "off Darwin the torch refuses rather than pretending" {
442 // The platform gate, exercised where it actually gates. On Darwin this
443 // same call would reach AVFoundation for real — see the test-section
444 // comment for why that is not done.
445 if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
446
447 var bridge = MiscBridge.init(testing.allocator);
448 defer bridge.deinit();
449
450 try testing.expectError(
451 error.UnsupportedPlatform,
452 bridge.handleMessage(A.set_flashlight, "{\"enabled\":true}"),
453 );
454}
packages/zig/src/bridge_mobile_permissions.zigadded+973-0
Changes to packages/zig/src/bridge_mobile_permissions.zig
@@ -0,0 +1,973 @@
1//! The permission actions of the `mobile` namespace: `checkPermission` and
2//! `requestPermission`.
3//!
4//! The page-side contract (`craft.permissions` in the injected JS) is one
5//! field in — `{permission: "camera"}` — and a bare JSON fragment out:
6//! `"granted"`, `"denied"`, `"restricted"` or `"undetermined"`. There is no
7//! `"prompt"` anywhere in the vocabulary, and no `{status:…}` wrapper — the
8//! desktop bridge's `craft.permissions` in `craft-bridge.js` (`t='permissions'`,
9//! payload field `name`, reply `{status:…}`) is a different, unrelated contract.
10//! Swift serialises replies with `.fragmentsAllowed`, so the bare quoted string
11//! is the wire value, and `ios_async`'s `"granted"`/`"denied"` completions are
12//! byte-identical to what Swift sends.
13//!
14//! ## What is served, and what deliberately is not
15//!
16//! Dispatch is per *action*, but honesty here is per *permission value*: Zig
17//! can read ten authorization statuses synchronously and can run three request
18//! prompts through `ios_async`'s pooled completion blocks, and it cannot do
19//! the rest without machinery it does not own yet. The split:
20//!
21//! - `checkPermission` is served for location, locationAlways, camera,
22//! microphone, photos, contacts, calendar, reminders, motion and
23//! bluetooth. `notifications` is handed back to the dispatcher as
24//! `UnknownAction`: the only status API is
25//! `getNotificationSettingsWithCompletionHandler:`, whose completion takes
26//! a `UNNotificationSettings *` and whose answer needs three words —
27//! `ios_async` speaks only BOOL blocks and only two. There is no
28//! synchronous API to fall back on, and answering without asking is the
29//! fabricated-reading class this migration exists to remove.
30//! - `requestPermission` is served for camera, microphone and notifications,
31//! whose completions are exactly the `void(^)(BOOL)` and
32//! `void(^)(BOOL, NSError*)` shapes the block pool was built for.
33//! location/locationAlways (the reply arrives via the app's
34//! `CLLocationManagerDelegate`, needs its `locationManager` and
35//! `enableBackgroundLocation` config, and speaks the full four-word
36//! vocabulary), photos (a status-enum completion with a `.limited`
37//! special case a BOOL cannot carry) and contacts/calendar/reminders
38//! (need a store instance kept alive past the dispatch frame, plus an
39//! iOS 17 availability fork) return `UnknownAction`.
40//! - `openSettings` is not claimed at all — not in `A`, not in the table.
41//! Its reply is a bare JSON *boolean* (`UIApplication.open`'s completion
42//! BOOL, `true`/`false` on the wire), and `ios_async.boolBlock` replies
43//! the strings `"granted"`/`"denied"`. That mismatch is not cosmetic:
44//! `false` is falsy and `"denied"` is truthy, so
45//! `if (await craft.permissions.openSettings())` would take its success
46//! branch on failure. Hand-building a one-off block here is off the table
47//! — the repo's last hand-rolled completion block was stack-allocated
48//! with a doc comment asserting a lifetime async does not honour — so
49//! claiming this action means first extending `ios_async` with a
50//! bool-literal reply variant. Until then the Swift shim's answer is
51//! correct and keeps flowing.
52//!
53//! Returning `UnknownAction` for a payload value is what keeps this honest
54//! without regressing anything: `ios_dispatch.route` treats it as "not mine,
55//! ask the next", finds no other module claiming the action, and hands the
56//! *same* action and payload to `handOffToHost`, where the un-migrated Swift
57//! arm answers exactly as it does today. This is the deliberate opposite of
58//! the `.unavailable` declarations in `bridge_mobile_device`
59//! (`getNetworkStatus`) and `bridge_mobile_display` (`lockOrientation`):
60//! those took actions *away* from the shim because the shim's answers were
61//! fabricated or broken; these shim answers are real, and taking them away
62//! would trade working behaviour for a tidier table. The cost is that the
63//! conformance ratchet keeps counting `openSettings` as not-yet-migrated —
64//! which is the truth.
65//!
66//! ## What is carried across exactly
67//!
68//! - The status vocabulary and its precedence: restricted beats denied
69//! beats granted, and everything else — including enum values a future
70//! iOS adds — is `"undetermined"`. That is Swift's `permissionStatus`
71//! helper verbatim. Unknown *permission names* also resolve
72//! `"undetermined"` (Swift's `default` arm); it is a real answer — the
73//! status of a permission iOS has never heard of is not determined — and
74//! erroring instead would diverge from both the shim and Android.
75//! - `requestPermission` for motion and bluetooth resolves
76//! `"undetermined"`: the Swift request switch has no arm for either, so
77//! they fall into `default`. iOS prompts for these implicitly at first
78//! framework use; the spec never requests them explicitly, and neither
79//! does this module. Resolving locally rather than falling through is
80//! identical behaviour, minus one Objective-C round trip.
81//! - `"location"` counts `.authorizedWhenInUse` as granted;
82//! `"locationAlways"` does not. Same raw status, different verdicts, and
83//! the check reads the same deprecated
84//! `+[CLLocationManager authorizationStatus]` the Swift spec calls —
85//! matching it, not modernising it, because the instance property needs a
86//! manager this module does not own.
87//!
88//! ## Framework presence, not framework linkage
89//!
90//! Unlike `bridge_mobile_storage`'s Security externs, nothing here is a
91//! link-time symbol: every class is found with `objc_getClass` at call time,
92//! and the one string constant (`AVMediaTypeVideo`) through `dlsym` with the
93//! documented literal value as fallback. A generated app has every class in
94//! the process because `CraftApp.swift` imports AVFoundation, Photos,
95//! Contacts, EventKit, CoreMotion, CoreBluetooth, CoreLocation and
96//! UserNotifications; a process missing one answers `ClassNotFound` →
97//! NATIVE_CALL_FAILED, never a crash and never a guess.
98
99const std = ;
100const builtin = ;
101const capabilities = ;
102const bridge_error = ;
103const objc_runtime = ;
104const ios_async = ;
105
106const objc = objc_runtime.objc;
107const is_darwin = builtin.target.os.tag.isDarwin();
108
109/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
110///
111/// `objc_runtime.objc` is an empty struct off Darwin, and a function
112/// *signature* is analysed even when a comptime platform guard makes its body
113/// unreachable, so naming `objc.id` in the signatures below would break the
114/// host build. It stays a single optional pointer, never `?objc.id`, because a
115/// double optional is illegal in a `callconv(.c)` type.
116const Id = ?*anyopaque;
117
118/// The action names, spelled exactly as the Swift `case` labels spell them.
119///
120/// `openSettings` is deliberately absent — see the module comment. Adding it
121/// here without first giving `ios_async` a boolean-literal reply would ship
122/// the truthy-`"denied"` bug, and the conformance scan would count the action
123/// as migrated while the shim was still the only honest server of it.
124pub const A = struct {
125 pub const check_permission = "checkPermission";
126 pub const request_permission = "requestPermission";
127};
128
129/// Both `.result`: every Swift path for these two actions terminates in
130/// exactly one `resolveCallback` or `rejectCallback`, and the injected
131/// `craft.permissions.check`/`request` return promises the page awaits.
132///
133/// Both `.live`, with the caveat the module comment spells out: the manifest
134/// speaks per action, and a handful of payload *values* ride through to the
135/// Swift shim via `UnknownAction`. The page cannot observe the difference —
136/// the same reply arrives either way — so `.live` is the accurate claim.
137pub const capability_actions = [_]capabilities.ActionDecl{
138 .{ .name = A.check_permission, .reply = .result },
139 .{ .name = A.request_permission, .reply = .result },
140};
141
142/// Which handler an action selects, or null for one this namespace does not
143/// serve. Split out from `handleMessage` so the table-versus-dispatch
144/// agreement is assertable on a host that has none of the frameworks.
145const Route = enum { check, request };
146
147fn routeFor(action: []const u8) ?Route {
148 if (std.mem.eql(u8, action, A.check_permission)) return .check;
149 if (std.mem.eql(u8, action, A.request_permission)) return .request;
150 return null;
151}
152
153/// The permission names the Swift switches recognise, plus `.unknown` for
154/// everything else — which is a routed outcome (`"undetermined"`), not an
155/// error, because it is Swift's `default` arm.
156///
157/// Spellings are the Swift `case` labels, compared case-sensitively, exactly
158/// as Swift compares them. The name is only ever *compared* — it never
159/// reaches `createNSString` — so an embedded NUL needs no refusal here: it
160/// fails every comparison and lands in `.unknown`, the same `"undetermined"`
161/// Swift gives it (Swift keeps the NUL and also falls to `default`).
162const Kind = enum {
163 location,
164 location_always,
165 camera,
166 microphone,
167 photos,
168 contacts,
169 calendar,
170 reminders,
171 motion,
172 bluetooth,
173 notifications,
174 unknown,
175};
176
177fn kindFor(name: []const u8) Kind {
178 if (std.mem.eql(u8, name, "location")) return .location;
179 if (std.mem.eql(u8, name, "locationAlways")) return .location_always;
180 if (std.mem.eql(u8, name, "camera")) return .camera;
181 if (std.mem.eql(u8, name, "microphone")) return .microphone;
182 if (std.mem.eql(u8, name, "photos")) return .photos;
183 if (std.mem.eql(u8, name, "contacts")) return .contacts;
184 if (std.mem.eql(u8, name, "calendar")) return .calendar;
185 if (std.mem.eql(u8, name, "reminders")) return .reminders;
186 if (std.mem.eql(u8, name, "motion")) return .motion;
187 if (std.mem.eql(u8, name, "bluetooth")) return .bluetooth;
188 if (std.mem.eql(u8, name, "notifications")) return .notifications;
189 return .unknown;
190}
191
192// =============================================================================
193// The status vocabulary — Swift's `permissionStatus` helper, verbatim.
194// =============================================================================
195
196const granted_json = "\"granted\"";
197const denied_json = "\"denied\"";
198const restricted_json = "\"restricted\"";
199const undetermined_json = "\"undetermined\"";
200
201/// restricted > denied > granted > undetermined, in that order of precedence.
202///
203/// The order is load-bearing and is Swift's: a status that somehow set both
204/// flags must answer `"restricted"`, and `granted == false` with no flag set
205/// is `"undetermined"`, not `"denied"` — notDetermined and every future enum
206/// value both land there, which is the honest word for either.
207fn statusJson(granted: bool, denied: bool, restricted: bool) []const u8 {
208 if (restricted) return restricted_json;
209 if (denied) return denied_json;
210 return if (granted) granted_json else undetermined_json;
211}
212
213/// `CLAuthorizationStatus` (an `Int32`, unlike every other status here):
214/// 0 notDetermined, 1 restricted, 2 denied, 3 authorizedAlways,
215/// 4 authorizedWhenInUse.
216///
217/// `always_only` is the one behavioural fork between the two location
218/// spellings: `"location"` counts whenInUse as granted, `"locationAlways"`
219/// counts only always — so a whenInUse grant checked as `"locationAlways"`
220/// answers `"undetermined"`, which is the spec's answer, odd as it reads.
221fn locationStatusJson(raw: i32, always_only: bool) []const u8 {
222 const granted = raw == 3 or (!always_only and raw == 4);
223 return statusJson(granted, raw == 2, raw == 1);
224}
225
226/// The 0..3 layout that AVFoundation (`AVAuthorizationStatus`), Contacts
227/// (`CNAuthorizationStatus`), CoreMotion (`CMAuthorizationStatus`) and
228/// CoreBluetooth (`CBManagerAuthorization`) all share: 0 notDetermined,
229/// 1 restricted, 2 denied, 3 authorized/allowedAlways.
230///
231/// One mapper for the four on purpose — they are the same numbers, and four
232/// copies would be four places for one transcription slip. Values past 3
233/// (e.g. the `.limited` Contacts gained in iOS 18) fold to `"undetermined"`,
234/// exactly as Swift's `status == .authorized`-only checks do.
235fn simpleAuthStatusJson(raw: c_long) []const u8 {
236 return statusJson(raw == 3, raw == 2, raw == 1);
237}
238
239/// `PHAuthorizationStatus`: as above, plus 4 limited — and limited *is*
240/// granted (`status == .authorized || status == .limited` in Swift). Only
241/// exactly 3 or 4; a future 5 is not presumed to be an access level.
242fn photosStatusJson(raw: c_long) []const u8 {
243 return statusJson(raw == 3 or raw == 4, raw == 2, raw == 1);
244}
245
246/// `EKAuthorizationStatus`: as the simple layout, but granted is
247/// `status == .authorized || status.rawValue >= 4` in Swift — i.e. `raw >= 3`
248/// — because iOS 17 renamed 3 to `.fullAccess` and added 4 `.writeOnly`, and
249/// the spec counts both.
250fn calendarStatusJson(raw: c_long) []const u8 {
251 return statusJson(raw >= 3, raw == 2, raw == 1);
252}
253
254/// The one status that is not a small enum: `AVAudioSessionRecordPermission`
255/// is an `NSUInteger` of four-character codes.
256fn fourCC(comptime s: *const [4]u8) c_ulong {
257 return (@as(c_ulong, s[0]) << 24) | (@as(c_ulong, s[1]) << 16) |
258 (@as(c_ulong, s[2]) << 8) | @as(c_ulong, s[3]);
259}
260
261const record_permission_undetermined: c_ulong = fourCC("undt");
262const record_permission_denied: c_ulong = fourCC("deny");
263const record_permission_granted: c_ulong = fourCC("grnt");
264
265/// Microphone has no restricted case — Swift passes only `granted` and
266/// `denied` to its helper — and anything unrecognised (including `'undt'`)
267/// is `"undetermined"`.
268fn microphoneStatusJson(raw: c_ulong) []const u8 {
269 return statusJson(raw == record_permission_granted, raw == record_permission_denied, false);
270}
271
272// =============================================================================
273// Payload
274// =============================================================================
275
276/// Parse `d`, distinguishing a bad payload from a failed allocation, exactly
277/// as `bridge_mobile_storage` does and for the same reason: telling the page
278/// INVALID_JSON about its own good JSON sends whoever debugs it to the wrong
279/// side of the bridge.
280fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
281 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
282 error.OutOfMemory => return err,
283 else => return bridge_error.BridgeError.InvalidJSON,
284 };
285}
286
287/// The one field the page sends: `permission`, a string. The injected JS
288/// builds the payload as `{permission: permission}` and `_invoke` merges in
289/// `action`/`callbackId`, so extra fields are normal and ignored.
290///
291/// Swift folds missing, `null` and non-string into a single
292/// `"Missing permission"` reject with code `INVALID_ARGUMENT`. This module's
293/// error vocabulary has no such code, so the cases split into the two nearest
294/// words — MISSING_DATA for an absent field, INVALID_PARAMETER for a present
295/// one of the wrong type. Every such case still *rejects* the promise, which
296/// is the part of the contract a page can be written against.
297fn permissionName(payload: std.json.Value) ![]const u8 {
298 const object = switch (payload) {
299 .object => |o| o,
300 else => return bridge_error.BridgeError.InvalidJSON,
301 };
302 const field = object.get("permission") orelse return bridge_error.BridgeError.MissingData;
303 return switch (field) {
304 .string => |s| s,
305 else => bridge_error.BridgeError.InvalidParameter,
306 };
307}
308
309pub const PermissionsBridge = struct {
310 allocator: std.mem.Allocator,
311
312 const Self = @This();
313
314 pub fn init(allocator: std.mem.Allocator) Self {
315 return .{ .allocator = allocator };
316 }
317
318 pub fn deinit(_: *Self) void {}
319
320 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
321 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
322 return switch (route) {
323 .check => self.checkPermission(data),
324 .request => self.requestPermission(data),
325 };
326 }
327
328 /// Read one authorization status and answer with the bare status string.
329 ///
330 /// Every served arm is synchronous and prompt-free — reading an
331 /// authorization status never triggers a dialog, including
332 /// `CBManager.authorization`, which unlike instantiating a
333 /// `CBCentralManager` does not start Bluetooth.
334 fn checkPermission(self: *Self, data: []const u8) !void {
335 var parsed = try parsePayload(self.allocator, data);
336 defer parsed.deinit();
337 const name = try permissionName(parsed.value);
338
339 const json: []const u8 = switch (kindFor(name)) {
340 .location => locationStatusJson(try readLocationStatus(), false),
341 .location_always => locationStatusJson(try readLocationStatus(), true),
342 .camera => simpleAuthStatusJson(try readCameraStatus(self.allocator)),
343 .microphone => microphoneStatusJson(try readMicrophonePermission()),
344 .photos => photosStatusJson(try readPhotosStatus()),
345 .contacts => simpleAuthStatusJson(try readContactsStatus()),
346 .calendar => calendarStatusJson(try readEventKitStatus(ek_entity_event)),
347 .reminders => calendarStatusJson(try readEventKitStatus(ek_entity_reminder)),
348 .motion => simpleAuthStatusJson(try readMotionStatus()),
349 .bluetooth => simpleAuthStatusJson(try readBluetoothStatus()),
350 // No sync API exists, and `ios_async` cannot carry a
351 // settings-object completion or a three-way answer. The shim
352 // asks `getNotificationSettingsWithCompletionHandler:` and
353 // resolves from inside it; hand the whole call to it, payload
354 // intact, rather than guess.
355 .notifications => return bridge_error.BridgeError.UnknownAction,
356 // Swift's `default:` — a real answer, see the module comment.
357 .unknown => undetermined_json,
358 };
359 bridge_error.sendResultToJS(self.allocator, A.check_permission, json);
360 }
361
362 /// Run a permission prompt whose completion fits `ios_async`, or hand the
363 /// value to the shim when it does not.
364 ///
365 /// The served arms reply later, from the completion, through the block
366 /// pool: `"granted"` or `"denied"`, byte-identical to Swift's
367 /// `granted ? "granted" : "denied"` under `.fragmentsAllowed`. If the
368 /// status is already determined, the frameworks call the completion
369 /// immediately with the standing answer — same as Swift, no prompt shown.
370 fn requestPermission(self: *Self, data: []const u8) !void {
371 var parsed = try parsePayload(self.allocator, data);
372 defer parsed.deinit();
373 const name = try permissionName(parsed.value);
374
375 switch (kindFor(name)) {
376 .camera => try self.requestCamera(),
377 .microphone => try requestMicrophone(),
378 .notifications => try requestNotifications(),
379 // Delegate replies, status-enum completions, store lifetimes and
380 // an iOS 17 fork — the shim owns all of that today. Same action,
381 // same payload, answered by the Swift arm unchanged.
382 .location, .location_always, .photos, .contacts, .calendar, .reminders => {
383 return bridge_error.BridgeError.UnknownAction;
384 },
385 // The Swift request switch has no motion or bluetooth arm; both
386 // fall into `default` and resolve "undetermined". iOS prompts for
387 // them implicitly at first framework use, never on request.
388 .motion, .bluetooth, .unknown => {
389 bridge_error.sendResultToJS(self.allocator, A.request_permission, undetermined_json);
390 },
391 }
392 }
393
394 /// `+[AVCaptureDevice requestAccessForMediaType:completionHandler:]` with
395 /// a `void(^)(BOOL)` from the pool.
396 ///
397 /// Every fallible step happens *before* `acquire`, so there is no error
398 /// path between the ticket and the framework call and therefore no
399 /// `abandon` site. A future edit that adds a fallible step after
400 /// `acquire` must call `ios_async.abandon(ticket)` on its error path, or
401 /// the slot leaks until the pool answers Busy forever.
402 fn requestCamera(self: *Self) !void {
403 if (!is_darwin) return error.UnsupportedPlatform;
404
405 const AVCaptureDevice = objc.objc_getClass("AVCaptureDevice") orelse return error.ClassNotFound;
406 const sel = objc.sel_registerName("requestAccessForMediaType:completionHandler:") orelse
407 return error.SelectorNotFound;
408 const media = try mediaTypeVideo(self.allocator);
409
410 const ticket = ios_async.acquire(A.request_permission) orelse return poolFull();
411
412 const Fn = *const fn (Id, Id, Id, *anyopaque) callconv(.c) void;
413 const func: Fn = @ptrCast(&objc.objc_msgSend);
414 func(AVCaptureDevice, sel, media, ios_async.boolBlock(ticket));
415 }
416
417 /// `-[AVAudioSession requestRecordPermission:]` on the shared instance —
418 /// the deprecated-but-live API the Swift spec calls; `AVAudioApplication`
419 /// is its replacement and would be a divergence, not a port.
420 fn requestMicrophone() !void {
421 if (!is_darwin) return error.UnsupportedPlatform;
422
423 const session = try audioSession();
424 const sel = objc.sel_registerName("requestRecordPermission:") orelse return error.SelectorNotFound;
425
426 const ticket = ios_async.acquire(A.request_permission) orelse return poolFull();
427
428 const Fn = *const fn (Id, Id, *anyopaque) callconv(.c) void;
429 const func: Fn = @ptrCast(&objc.objc_msgSend);
430 func(session, sel, ios_async.boolBlock(ticket));
431 }
432
433 /// `-[UNUserNotificationCenter requestAuthorizationWithOptions:completionHandler:]`
434 /// with a `void(^)(BOOL, NSError *)` from the pool. The error object is
435 /// discarded by the pool's invoke, exactly as Swift's `{ granted, _ in }`
436 /// discards it — the granted flag is the whole page contract.
437 ///
438 /// `currentNotificationCenter` raises (rather than returning nil) in a
439 /// process with no bundle identifier; that is unreachable from a
440 /// dispatched page message, which only exists inside a bundled app, and
441 /// it is the same exposure the Swift call has.
442 fn requestNotifications() !void {
443 if (!is_darwin) return error.UnsupportedPlatform;
444
445 const UNUserNotificationCenter = objc.objc_getClass("UNUserNotificationCenter") orelse
446 return error.ClassNotFound;
447 const sel_current = objc.sel_registerName("currentNotificationCenter") orelse
448 return error.SelectorNotFound;
449 const center = objc.msgSendId(UNUserNotificationCenter, sel_current) orelse
450 return error.NoNotificationCenter;
451 const sel = objc.sel_registerName("requestAuthorizationWithOptions:completionHandler:") orelse
452 return error.SelectorNotFound;
453
454 const ticket = ios_async.acquire(A.request_permission) orelse return poolFull();
455
456 const Fn = *const fn (Id, Id, c_ulong, *anyopaque) callconv(.c) void;
457 const func: Fn = @ptrCast(&objc.objc_msgSend);
458 func(center, sel, un_options_alert_badge_sound, ios_async.boolErrorBlock(ticket));
459 }
460};
461
462/// The answer for a full block pool. `BridgeError` has no "Busy";
463/// INVALID_PARAMETER is the designated stand-in from the migration notes,
464/// and the point is that the sixteen-plus-first concurrent prompt gets an
465/// explicit rejection instead of a promise that never settles.
466fn poolFull() bridge_error.BridgeError {
467 std.log.warn("permission request refused: all {d} async slots in flight", .{ios_async.max_in_flight});
468 return bridge_error.BridgeError.InvalidParameter;
469}
470
471// =============================================================================
472// Framework constants — transcribed, so pinned by tests below.
473// =============================================================================
474
475/// `EKEntityType`: NSUInteger. event = 0, reminder = 1.
476const ek_entity_event: c_ulong = 0;
477const ek_entity_reminder: c_ulong = 1;
478
479/// `PHAccessLevel.readWrite` = 2 (NSInteger). `.addOnly` is 1; the spec
480/// checks readWrite.
481const ph_access_level_read_write: c_long = 2;
482
483/// `CNEntityType.contacts` = 0 (NSInteger) — the only entity type there is.
484const cn_entity_type_contacts: c_long = 0;
485
486/// `UNAuthorizationOptions`: badge 1<<0 | sound 1<<1 | alert 1<<2 = 7,
487/// matching Swift's `[.alert, .badge, .sound]` exactly — no provisional, no
488/// carPlay, nothing the spec did not ask for.
489const un_options_alert_badge_sound: c_ulong = 7;
490
491// =============================================================================
492// Status readers. Runtime class lookups only — no link-time framework
493// dependency; see the module comment.
494// =============================================================================
495
496/// `+[CLLocationManager authorizationStatus]`. Deprecated since iOS 14 in
497/// favour of the instance property, but it is the exact call the Swift spec
498/// makes, needs no manager instance and no delegate, and reading it never
499/// prompts. `CLAuthorizationStatus` is an `Int32` — the one status here that
500/// is not NSInteger-sized, and a `c_long` cast would read 32 bits of
501/// register garbage above it on a big-endian day; `i32` says what it is.
502fn readLocationStatus() !i32 {
503 if (!is_darwin) return error.UnsupportedPlatform;
504
505 const CLLocationManager = objc.objc_getClass("CLLocationManager") orelse return error.ClassNotFound;
506 const sel = objc.sel_registerName("authorizationStatus") orelse return error.SelectorNotFound;
507 const Fn = *const fn (Id, Id) callconv(.c) i32;
508 const func: Fn = @ptrCast(&objc.objc_msgSend);
509 return func(CLLocationManager, sel);
510}
511
512/// `+[AVCaptureDevice authorizationStatusForMediaType:]` with
513/// `AVMediaTypeVideo`.
514fn readCameraStatus(allocator: std.mem.Allocator) !c_long {
515 if (!is_darwin) return error.UnsupportedPlatform;
516
517 const AVCaptureDevice = objc.objc_getClass("AVCaptureDevice") orelse return error.ClassNotFound;
518 const sel = objc.sel_registerName("authorizationStatusForMediaType:") orelse return error.SelectorNotFound;
519 const media = try mediaTypeVideo(allocator);
520 const Fn = *const fn (Id, Id, Id) callconv(.c) c_long;
521 const func: Fn = @ptrCast(&objc.objc_msgSend);
522 return func(AVCaptureDevice, sel, media);
523}
524
525/// `-[AVAudioSession recordPermission]` on the shared instance.
526fn readMicrophonePermission() !c_ulong {
527 if (!is_darwin) return error.UnsupportedPlatform;
528
529 const session = try audioSession();
530 const sel = objc.sel_registerName("recordPermission") orelse return error.SelectorNotFound;
531 const Fn = *const fn (Id, Id) callconv(.c) c_ulong;
532 const func: Fn = @ptrCast(&objc.objc_msgSend);
533 return func(session, sel);
534}
535
536/// `+[PHPhotoLibrary authorizationStatusForAccessLevel:]` — the level-aware
537/// form the spec calls (`for: .readWrite`), not the level-less deprecated one,
538/// which answers `.authorized` for limited access and would erase the
539/// distinction `photosStatusJson` exists to keep.
540fn readPhotosStatus() !c_long {
541 if (!is_darwin) return error.UnsupportedPlatform;
542
543 const PHPhotoLibrary = objc.objc_getClass("PHPhotoLibrary") orelse return error.ClassNotFound;
544 const sel = objc.sel_registerName("authorizationStatusForAccessLevel:") orelse return error.SelectorNotFound;
545 const Fn = *const fn (Id, Id, c_long) callconv(.c) c_long;
546 const func: Fn = @ptrCast(&objc.objc_msgSend);
547 return func(PHPhotoLibrary, sel, ph_access_level_read_write);
548}
549
550/// `+[CNContactStore authorizationStatusForEntityType:]`. A class method —
551/// no store instance is needed to *read*, which is what keeps this arm
552/// servable while the request arm is not.
553fn readContactsStatus() !c_long {
554 if (!is_darwin) return error.UnsupportedPlatform;
555
556 const CNContactStore = objc.objc_getClass("CNContactStore") orelse return error.ClassNotFound;
557 const sel = objc.sel_registerName("authorizationStatusForEntityType:") orelse return error.SelectorNotFound;
558 const Fn = *const fn (Id, Id, c_long) callconv(.c) c_long;
559 const func: Fn = @ptrCast(&objc.objc_msgSend);
560 return func(CNContactStore, sel, cn_entity_type_contacts);
561}
562
563/// `+[EKEventStore authorizationStatusForEntityType:]`, shared by calendar
564/// (event = 0) and reminders (reminder = 1).
565fn readEventKitStatus(entity: c_ulong) !c_long {
566 if (!is_darwin) return error.UnsupportedPlatform;
567
568 const EKEventStore = objc.objc_getClass("EKEventStore") orelse return error.ClassNotFound;
569 const sel = objc.sel_registerName("authorizationStatusForEntityType:") orelse return error.SelectorNotFound;
570 const Fn = *const fn (Id, Id, c_ulong) callconv(.c) c_long;
571 const func: Fn = @ptrCast(&objc.objc_msgSend);
572 return func(EKEventStore, sel, entity);
573}
574
575/// `+[CMMotionActivityManager authorizationStatus]`.
576fn readMotionStatus() !c_long {
577 if (!is_darwin) return error.UnsupportedPlatform;
578
579 const CMMotionActivityManager = objc.objc_getClass("CMMotionActivityManager") orelse return error.ClassNotFound;
580 const sel = objc.sel_registerName("authorizationStatus") orelse return error.SelectorNotFound;
581 const Fn = *const fn (Id, Id) callconv(.c) c_long;
582 const func: Fn = @ptrCast(&objc.objc_msgSend);
583 return func(CMMotionActivityManager, sel);
584}
585
586/// `+[CBManager authorization]` — the class property getter, whose selector
587/// is just the property name. Reading it does not start Bluetooth and does
588/// not prompt, unlike instantiating a central manager.
589fn readBluetoothStatus() !c_long {
590 if (!is_darwin) return error.UnsupportedPlatform;
591
592 const CBManager = objc.objc_getClass("CBManager") orelse return error.ClassNotFound;
593 const sel = objc.sel_registerName("authorization") orelse return error.SelectorNotFound;
594 const Fn = *const fn (Id, Id) callconv(.c) c_long;
595 const func: Fn = @ptrCast(&objc.objc_msgSend);
596 return func(CBManager, sel);
597}
598
599/// `+[AVAudioSession sharedInstance]`, shared by the microphone read and
600/// request arms.
601fn audioSession() !Id {
602 if (!is_darwin) return error.UnsupportedPlatform;
603
604 const AVAudioSession = objc.objc_getClass("AVAudioSession") orelse return error.ClassNotFound;
605 const sel = objc.sel_registerName("sharedInstance") orelse return error.SelectorNotFound;
606 return objc.msgSendId(AVAudioSession, sel) orelse error.NoAudioSession;
607}
608
609// -----------------------------------------------------------------------------
610// AVMediaTypeVideo
611// -----------------------------------------------------------------------------
612
613extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
614
615/// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin.
616const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
617
618/// The `AVMediaTypeVideo` constant, without a link-time AVFoundation
619/// dependency.
620///
621/// Extern-linking the `NSString *const` the way `bridge_mobile_storage` links
622/// `kSec*` would force `-framework AVFoundation` onto every consumer of the
623/// static library — a build-system change no other part of this module needs,
624/// since classes are found at runtime. So: `dlsym` the symbol out of whatever
625/// loaded AVFoundation (if `AVCaptureDevice` resolved, it is loaded and the
626/// symbol is there), and fall back to the documented constant *value*,
627/// `@"vide"`, which AVFoundation compares by content — a media type travels
628/// through dictionaries and `isEqual:`, never pointer identity, which is also
629/// why Swift's bridged strings work.
630fn mediaTypeVideo(allocator: std.mem.Allocator) !Id {
631 if (!is_darwin) return error.UnsupportedPlatform;
632
633 if (dlsym(RTLD_DEFAULT, "AVMediaTypeVideo")) |sym| {
634 // The symbol is the *variable* — an `NSString *` — so one dereference
635 // yields the object. Guarded: a resolved symbol holding nil would
636 // otherwise put nil into an ObjC argument that must not be.
637 const slot: *Id = @ptrCast(@alignCast(sym));
638 if (slot.*) |ns| return ns;
639 }
640 const ns = try objc.createNSString("vide", allocator);
641 if (ns == null) return error.NSStringCreationFailed;
642 return ns;
643}
644
645// =============================================================================
646// Tests. Host-only by construction: everything that decides what the page
647// sees — routing, name mapping, status mapping, payload validation — is a
648// pure function beside the readers, and no test dispatches a permission kind
649// whose reader would touch a live framework (on a developer's Mac some of
650// these classes exist, and a *request* arm would put a real TCC prompt on
651// someone's screen).
652// =============================================================================
653
654const testing = std.testing;
655
656test "the declared actions are the ones the handler serves" {
657 try testing.expectEqual(@as(usize, 2), capability_actions.len);
658 try testing.expectEqualStrings(A.check_permission, capability_actions[0].name);
659 try testing.expectEqualStrings(A.request_permission, capability_actions[1].name);
660
661 for (capability_actions) |decl| {
662 try testing.expectEqual(capabilities.Reply.result, decl.reply);
663 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
664 }
665}
666
667test "the action names are the ones the Swift dispatcher answers" {
668 // The wire contract, spelled out. The conformance scan catches a name the
669 // spec lacks; it cannot catch both being renamed in step, which this holds.
670 try testing.expectEqualStrings("checkPermission", A.check_permission);
671 try testing.expectEqualStrings("requestPermission", A.request_permission);
672}
673
674test "every declared action is one the dispatcher routes" {
675 for (capability_actions) |decl| {
676 if (routeFor(decl.name) == null) {
677 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
678 return error.DeclaredActionDoesNotRoute;
679 }
680 }
681}
682
683test "every route the dispatcher has is a declared action" {
684 // Each declaration must claim a distinct route and every route must be
685 // claimed — counting alone would let two rows share one route while
686 // another went undeclared.
687 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
688 for (capability_actions) |decl| {
689 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
690 const slot = @backingInt(route);
691 if (claimed[slot]) return error.TwoDeclarationsShareARoute;
692 claimed[slot] = true;
693 }
694 for (claimed) |taken| {
695 if (!taken) return error.RouteNotDeclared;
696 }
697}
698
699test "openSettings is left to the Swift shim on purpose" {
700 // The pin for the module comment's biggest decision. Its reply is a bare
701 // boolean, `ios_async.boolBlock` replies "granted"/"denied", and "denied"
702 // is truthy — a page's `if (await openSettings())` would take the success
703 // branch on failure. Whoever claims this action deletes this test
704 // *after* giving ios_async a boolean-literal reply, not before.
705 var bridge = PermissionsBridge.init(testing.allocator);
706 defer bridge.deinit();
707
708 try testing.expectError(
709 bridge_error.BridgeError.UnknownAction,
710 bridge.handleMessage("openSettings", "{}"),
711 );
712 for (capability_actions) |decl| {
713 try testing.expect(!std.mem.eql(u8, decl.name, "openSettings"));
714 }
715}
716
717test "an action the namespace does not serve is reported, not ignored" {
718 var bridge = PermissionsBridge.init(testing.allocator);
719 defer bridge.deinit();
720
721 try testing.expectError(
722 bridge_error.BridgeError.UnknownAction,
723 bridge.handleMessage("noSuchAction", "{}"),
724 );
725 // Casing is how a real typo arrives.
726 try testing.expectError(
727 bridge_error.BridgeError.UnknownAction,
728 bridge.handleMessage("checkpermission", "{}"),
729 );
730 // The Swift helper's name, which is not an action name.
731 try testing.expectError(
732 bridge_error.BridgeError.UnknownAction,
733 bridge.handleMessage("permissionStatus", "{}"),
734 );
735 // The desktop namespace's method names, which are a different bridge.
736 try testing.expectError(
737 bridge_error.BridgeError.UnknownAction,
738 bridge.handleMessage("check", "{\"permission\":\"camera\"}"),
739 );
740}
741
742test "every declared action dispatches to something" {
743 // `{}` has no `permission`, so both fail validation *before* any
744 // framework call — which is what makes this safe on a host. What it rules
745 // out is a name in the table `handleMessage` does not compare against.
746 var bridge = PermissionsBridge.init(testing.allocator);
747 defer bridge.deinit();
748
749 for (capability_actions) |decl| {
750 try testing.expectError(
751 bridge_error.BridgeError.MissingData,
752 bridge.handleMessage(decl.name, "{}"),
753 );
754 }
755}
756
757test "a malformed payload is reported as bad JSON, not as a missing field" {
758 var bridge = PermissionsBridge.init(testing.allocator);
759 defer bridge.deinit();
760
761 try testing.expectError(
762 bridge_error.BridgeError.InvalidJSON,
763 bridge.handleMessage(A.check_permission, "{not json"),
764 );
765 try testing.expectError(
766 bridge_error.BridgeError.InvalidJSON,
767 bridge.handleMessage(A.request_permission, "{not json"),
768 );
769}
770
771fn expectPermissionName(json: []const u8, expected: []const u8) !void {
772 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
773 defer parsed.deinit();
774 try testing.expectEqualStrings(expected, try permissionName(parsed.value));
775}
776
777fn expectPermissionNameError(json: []const u8, expected: anyerror) !void {
778 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
779 defer parsed.deinit();
780 try testing.expectError(expected, permissionName(parsed.value));
781}
782
783test "the permission field is the one the page sends, extras ignored" {
784 try expectPermissionName("{\"permission\":\"camera\"}", "camera");
785 // `_invoke` merges `action` and `callbackId` into the payload it posts;
786 // their presence must not disturb the read.
787 try expectPermissionName(
788 "{\"permission\":\"microphone\",\"action\":\"checkPermission\",\"callbackId\":\"cb_1\"}",
789 "microphone",
790 );
791}
792
793test "a missing, null, or non-string permission is refused" {
794 // Swift folds all three into one "Missing permission" INVALID_ARGUMENT
795 // reject; the codes here split into the two nearest words, but every case
796 // still rejects — no arm defaults the field or falls through silently.
797 try expectPermissionNameError("{}", bridge_error.BridgeError.MissingData);
798 try expectPermissionNameError("{\"permission\":null}", bridge_error.BridgeError.InvalidParameter);
799 try expectPermissionNameError("{\"permission\":7}", bridge_error.BridgeError.InvalidParameter);
800 try expectPermissionNameError("[]", bridge_error.BridgeError.InvalidJSON);
801 try expectPermissionNameError("\"camera\"", bridge_error.BridgeError.InvalidJSON);
802}
803
804test "the eleven spec spellings map to their kinds" {
805 try testing.expectEqual(Kind.location, kindFor("location"));
806 try testing.expectEqual(Kind.location_always, kindFor("locationAlways"));
807 try testing.expectEqual(Kind.camera, kindFor("camera"));
808 try testing.expectEqual(Kind.microphone, kindFor("microphone"));
809 try testing.expectEqual(Kind.photos, kindFor("photos"));
810 try testing.expectEqual(Kind.contacts, kindFor("contacts"));
811 try testing.expectEqual(Kind.calendar, kindFor("calendar"));
812 try testing.expectEqual(Kind.reminders, kindFor("reminders"));
813 try testing.expectEqual(Kind.motion, kindFor("motion"));
814 try testing.expectEqual(Kind.bluetooth, kindFor("bluetooth"));
815 try testing.expectEqual(Kind.notifications, kindFor("notifications"));
816}
817
818test "an unrecognised permission resolves undetermined, as the spec's default arm" {
819 try testing.expectEqual(Kind.unknown, kindFor("telepathy"));
820 // Case-sensitive, as Swift's switch is.
821 try testing.expectEqual(Kind.unknown, kindFor("Camera"));
822 try testing.expectEqual(Kind.unknown, kindFor(""));
823
824 // End to end: both actions *resolve* for an unknown name — no error, no
825 // shim fall-through — because that is what the Swift default arm does.
826 var bridge = PermissionsBridge.init(testing.allocator);
827 defer bridge.deinit();
828 try bridge.handleMessage(A.check_permission, "{\"permission\":\"telepathy\"}");
829 try bridge.handleMessage(A.request_permission, "{\"permission\":\"telepathy\"}");
830}
831
832test "a NUL-carrying permission lands in unknown rather than being truncated" {
833 // The name is only compared, never handed to `stringWithUTF8String:`, so
834 // there is nothing to truncate and no refusal is needed: "camera\x00x"
835 // is not "camera", fails every comparison, and answers "undetermined" —
836 // the same answer Swift's default arm gives the NUL-keeping original.
837 try testing.expectEqual(Kind.unknown, kindFor("camera\x00x"));
838
839 var bridge = PermissionsBridge.init(testing.allocator);
840 defer bridge.deinit();
841 try bridge.handleMessage(A.check_permission, "{\"permission\":\"camera\\u0000x\"}");
842}
843
844test "restricted outranks denied outranks granted" {
845 // Swift's helper checks in this order, so a status that set two flags
846 // answers the severest one. The spellings must also match ios_async's
847 // hard-coded completion replies byte for byte, or one action would speak
848 // two dialects depending on which arm answered.
849 try testing.expectEqualStrings("\"restricted\"", statusJson(true, true, true));
850 try testing.expectEqualStrings("\"denied\"", statusJson(true, true, false));
851 try testing.expectEqualStrings("\"granted\"", statusJson(true, false, false));
852 try testing.expectEqualStrings("\"undetermined\"", statusJson(false, false, false));
853}
854
855test "location statuses map per the spec, whenInUse counting only for 'location'" {
856 // 0 notDetermined, 1 restricted, 2 denied, 3 always, 4 whenInUse.
857 try testing.expectEqualStrings("\"undetermined\"", locationStatusJson(0, false));
858 try testing.expectEqualStrings("\"restricted\"", locationStatusJson(1, false));
859 try testing.expectEqualStrings("\"denied\"", locationStatusJson(2, false));
860 try testing.expectEqualStrings("\"granted\"", locationStatusJson(3, false));
861 try testing.expectEqualStrings("\"granted\"", locationStatusJson(4, false));
862
863 // The fork: a whenInUse grant checked as "locationAlways" is not granted
864 // — and not denied either. "undetermined" is the spec's answer.
865 try testing.expectEqualStrings("\"granted\"", locationStatusJson(3, true));
866 try testing.expectEqualStrings("\"undetermined\"", locationStatusJson(4, true));
867
868 // A future status value is not guessed at.
869 try testing.expectEqualStrings("\"undetermined\"", locationStatusJson(5, false));
870}
871
872test "the shared 0..3 layout maps for camera, contacts, motion and bluetooth" {
873 try testing.expectEqualStrings("\"undetermined\"", simpleAuthStatusJson(0));
874 try testing.expectEqualStrings("\"restricted\"", simpleAuthStatusJson(1));
875 try testing.expectEqualStrings("\"denied\"", simpleAuthStatusJson(2));
876 try testing.expectEqualStrings("\"granted\"", simpleAuthStatusJson(3));
877 // iOS 18's CNAuthorizationStatus.limited (4) and anything later: Swift's
878 // `status == .authorized` is false for them, so "undetermined" — folded,
879 // not promoted.
880 try testing.expectEqualStrings("\"undetermined\"", simpleAuthStatusJson(4));
881}
882
883test "photos limited counts as granted" {
884 try testing.expectEqualStrings("\"granted\"", photosStatusJson(3));
885 try testing.expectEqualStrings("\"granted\"", photosStatusJson(4));
886 try testing.expectEqualStrings("\"denied\"", photosStatusJson(2));
887 try testing.expectEqualStrings("\"restricted\"", photosStatusJson(1));
888 try testing.expectEqualStrings("\"undetermined\"", photosStatusJson(0));
889 // Unlike EventKit, only exactly limited is promoted; 5 is not presumed to
890 // be an access level.
891 try testing.expectEqualStrings("\"undetermined\"", photosStatusJson(5));
892}
893
894test "calendar fullAccess and writeOnly both count as granted" {
895 // Swift: `status == .authorized || status.rawValue >= 4` — the iOS 17
896 // renames land on 3 (fullAccess) and 4 (writeOnly).
897 try testing.expectEqualStrings("\"granted\"", calendarStatusJson(3));
898 try testing.expectEqualStrings("\"granted\"", calendarStatusJson(4));
899 try testing.expectEqualStrings("\"granted\"", calendarStatusJson(5));
900 try testing.expectEqualStrings("\"denied\"", calendarStatusJson(2));
901 try testing.expectEqualStrings("\"restricted\"", calendarStatusJson(1));
902 try testing.expectEqualStrings("\"undetermined\"", calendarStatusJson(0));
903}
904
905test "the microphone four-char codes are the documented ones" {
906 // AVAudioSessionRecordPermission is the one status that is not a small
907 // enum, and a mistranscribed code would silently reclassify every answer.
908 try testing.expectEqual(@as(c_ulong, 1970168948), record_permission_undetermined);
909 try testing.expectEqual(@as(c_ulong, 1684369017), record_permission_denied);
910 try testing.expectEqual(@as(c_ulong, 1735552628), record_permission_granted);
911
912 try testing.expectEqualStrings("\"granted\"", microphoneStatusJson(record_permission_granted));
913 try testing.expectEqualStrings("\"denied\"", microphoneStatusJson(record_permission_denied));
914 try testing.expectEqualStrings("\"undetermined\"", microphoneStatusJson(record_permission_undetermined));
915 // No restricted case exists for the microphone, and junk is not guessed at.
916 try testing.expectEqualStrings("\"undetermined\"", microphoneStatusJson(0));
917}
918
919test "what Zig cannot answer honestly falls through to the shim, payload intact" {
920 // UnknownAction is the dispatcher's "not mine, ask the next"; the chain
921 // ends at handOffToHost with the same action and the same `d`, and the
922 // un-migrated Swift arm answers as it always has. Any *other* error here
923 // would be final and would take a working reply away from the page.
924 var bridge = PermissionsBridge.init(testing.allocator);
925 defer bridge.deinit();
926
927 try testing.expectError(
928 bridge_error.BridgeError.UnknownAction,
929 bridge.handleMessage(A.check_permission, "{\"permission\":\"notifications\"}"),
930 );
931
932 const deferred_requests = [_][]const u8{
933 "{\"permission\":\"location\"}",
934 "{\"permission\":\"locationAlways\"}",
935 "{\"permission\":\"photos\"}",
936 "{\"permission\":\"contacts\"}",
937 "{\"permission\":\"calendar\"}",
938 "{\"permission\":\"reminders\"}",
939 };
940 for (deferred_requests) |payload| {
941 try testing.expectError(
942 bridge_error.BridgeError.UnknownAction,
943 bridge.handleMessage(A.request_permission, payload),
944 );
945 }
946}
947
948test "requesting motion or bluetooth resolves undetermined, as the spec does" {
949 // The Swift request switch has no arm for either — both fall into
950 // `default` and resolve "undetermined" without prompting. Erroring or
951 // falling through would change behaviour the page can already observe.
952 var bridge = PermissionsBridge.init(testing.allocator);
953 defer bridge.deinit();
954
955 try bridge.handleMessage(A.request_permission, "{\"permission\":\"motion\"}");
956 try bridge.handleMessage(A.request_permission, "{\"permission\":\"bluetooth\"}");
957}
958
959test "the notification options are Swift's [.alert, .badge, .sound]" {
960 // badge 1<<0, sound 1<<1, alert 1<<2. A wrong mask here would not fail —
961 // it would quietly request a different set of capabilities than every
962 // existing app was granted under.
963 try testing.expectEqual(@as(c_ulong, 1 | 2 | 4), un_options_alert_badge_sound);
964}
965
966test "the framework constants that are passed as arguments are the documented ones" {
967 // Each of these rides into objc_msgSend as a raw integer; a wrong value
968 // is not an error but a question about a different entity.
969 try testing.expectEqual(@as(c_ulong, 0), ek_entity_event);
970 try testing.expectEqual(@as(c_ulong, 1), ek_entity_reminder);
971 try testing.expectEqual(@as(c_long, 2), ph_access_level_read_write);
972 try testing.expectEqual(@as(c_long, 0), cn_entity_type_contacts);
973}
packages/zig/src/bridge_mobile_securestore.zigadded+884-0
Changes to packages/zig/src/bridge_mobile_securestore.zig
@@ -0,0 +1,884 @@
1//! The secure-storage actions of the `mobile` namespace: `secureSet`,
2//! `secureGet`, `secureRemove`, `secureClear`.
3//!
4//! **Same keychain, same namespace as `bridge_mobile_storage.zig`.** The Swift
5//! helpers (`secureStore`/`secureRetrieve`/`secureRemove`/`secureClear` in
6//! `CraftApp.swift`) identify an item by `kSecClass` + `kSecAttrAccount` and
7//! nothing else — no `kSecAttrService` anywhere in the file, no key prefix, no
8//! access group. That means `secureSet("k")` and `setSharedItem("k")` (group
9//! omitted) read and overwrite the *same item*. Adding a service attribute here
10//! would look like hygiene and would actually make every item the Swift shim
11//! ever wrote unreadable, which is the one thing a partial migration must not
12//! do. The overlap is Swift's design, carried across on purpose.
13//!
14//! Two JS surfaces funnel into these actions: the legacy `craft.secureStore`
15//! object (`set`/`get`/`remove`, promises with **no timeout**) and the modern
16//! `craft.secureStorage` wrapper, which delegates set/get/delete to the legacy
17//! object and reaches `secureClear` through `craft._invoke` (30s timeout). The
18//! no-timeout legacy promises are why every path in this file ends in a reply
19//! or an error — a dropped message parks the page forever.
20//!
21//! ## What is carried across exactly, because it is the observable contract
22//!
23//! - **Replies are bare JSON fragments**, Swift's `.fragmentsAllowed`:
24//! `true`/`false` for set/remove/clear, a bare string or `null` for get.
25//! No object wrapper anywhere, unlike the sharedItems shapes next door.
26//! - **A keychain failure on set/remove/clear *resolves* `false`** rather than
27//! rejecting. That is not fabricated success — `false` is the contract's
28//! failure value, and it is what every existing caller is written against.
29//! The raw OSStatus goes to the log, which is the only channel a bare
30//! boolean leaves for it.
31//! - **`remove` and `clear` treat `errSecItemNotFound` as `true`** — both are
32//! idempotent and a caller cannot act on the difference.
33//! - **Accessibility is `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`**, on
34//! the add only.
35//! - **`get` answers `null` for a missing key** — the promise resolves, it
36//! does not reject.
37//!
38//! ## What is deliberately not carried across
39//!
40//! **Swift's silent hang.** The set/get/remove dispatcher arms are `if let`s
41//! with no `else`: a missing `key`, a non-string `value`, or a disabled
42//! `enableSecureStorage` config replies nothing at all, and the legacy promise
43//! never settles. Malformed input errors here instead. The capability gate has
44//! no Zig mirror (`enableSecureStorage` appears nowhere in `packages/zig/src`),
45//! so the actions are served unconditionally. For set/get/remove that grants a
46//! page nothing new: the *ungated* sharedItems actions next door already
47//! read and write the same items (one namespace — see above). The two gated
48//! modules that came before chose differently — clipboard kept its gate
49//! plumbable (`craft_ios_set_clipboard_enabled`, rejecting `PermissionDenied`
50//! when off) and haptics declared its gated action `.unavailable` — and
51//! `secureClear` is where the difference is real: under Swift's default-false
52//! gate a page got CAPABILITY_DISABLED instead of a wipe, and here the wipe is
53//! always live. If that gate is wanted back, clipboard's hook is the pattern;
54//! what must not come back is Swift's silent hang on the other three.
55//!
56//! **`get`'s collapse of every failure into `null`.** Swift returns `nil` for
57//! *any* non-success status, so a page cannot tell "nothing stored" from "the
58//! keychain call failed". Here only `errSecItemNotFound` is `null`; any other
59//! status is an error with the number in the log. Same for stored bytes that
60//! are not UTF-8 (Swift's `String(data:encoding:)` fails and resolves `null`
61//! over a value that *exists*): that is an error here, because `null` is a
62//! claim about the store that nothing verified.
63//!
64//! **A `key` with an embedded NUL.** Swift stores it whole; the route Zig has
65//! to the keychain (`stringWithUTF8String:`) truncates at the NUL and would
66//! file the item under a shorter name than the caller gave, then report success
67//! for a key it did not use. Refused as `InvalidParameter`, exactly as
68//! `bridge_mobile_storage.requireNulFree` does. The *value* keeps its NUL: it
69//! travels as bytes-plus-length through `dataWithBytes:length:` and back out
70//! through `length`/`bytes`, so nothing truncates it, and refusing it would
71//! drop a payload the handler carries perfectly well.
72//!
73//! **Swift's shared delete/add dictionary.** `secureStore` hands the same
74//! dictionary — `kSecValueData` and `kSecAttrAccessible` included — to both
75//! `SecItemDelete` and `SecItemAdd` and discards the delete's status.
76//! `keychainStore` below builds the delete-scoped query first (class + account,
77//! the shape `remove` already uses) and only then adds the value attributes;
78//! `bridge_mobile_storage.zig`'s module comment carries the full argument.
79//!
80//! ## The `secureClear` blast radius, named because it is invisible
81//!
82//! Swift's `secureClear` deletes with a query of `kSecClass` alone: **every
83//! generic-password item the app can see**, including everything
84//! `setSharedItem` wrote (the two namespaces are one namespace — see above).
85//! `keychainClear` is bug-compatible with that, because a narrower clear would
86//! leave items behind that Swift's would have removed and the two
87//! implementations must be interchangeable mid-migration. A page that calls
88//! `craft.secureStorage.clear()` wipes the sharedItems store too, whichever
89//! language answers. This is also why the host tests below never *invoke* the
90//! clear handler: it has no validation step to fail early at, and on a macOS
91//! test runner the query would address the developer's login keychain.
92
93const std = ;
94const builtin = ;
95const capabilities = ;
96const bridge_error = ;
97const objc_runtime = ;
98
99const objc = objc_runtime.objc;
100
101/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
102///
103/// `objc_runtime.objc` is an empty struct off Darwin, and a function
104/// *signature* is analysed even when a comptime platform guard makes its body
105/// unreachable, so naming `objc.id` in the `callconv(.c)` types below would
106/// break the host build. It stays a single optional pointer, never `?objc.id`:
107/// a double optional is illegal in a `callconv(.c)` type.
108const Id = ?*anyopaque;
109
110/// Toll-free-bridged CoreFoundation values — the `kSec*` globals — kept
111/// nominally distinct from `Id` in the signatures, as in
112/// `bridge_mobile_storage.zig`.
113const CFTypeRef = ?*anyopaque;
114
115/// `SInt32`, fixed by the ABI; every keychain status of interest is negative.
116const OSStatus = i32;
117
118// Security.framework and CoreFoundation. Duplicated from
119// `bridge_mobile_storage.zig` rather than imported from it: these are
120// file-private there by design, and `extern` declarations are free to repeat.
121// The build note there applies here too — `-framework Security` must reach the
122// app link, which Swift's `import Security` autolinks and the zig-slice fixture
123// passes explicitly.
124extern "c" fn SecItemAdd(attributes: CFTypeRef, result: ?*CFTypeRef) OSStatus;
125extern "c" fn SecItemCopyMatching(query: CFTypeRef, result: ?*CFTypeRef) OSStatus;
126extern "c" fn SecItemDelete(query: CFTypeRef) OSStatus;
127extern "c" fn CFRelease(cf: CFTypeRef) void;
128
129extern "c" const kSecClass: CFTypeRef;
130extern "c" const kSecClassGenericPassword: CFTypeRef;
131extern "c" const kSecAttrAccount: CFTypeRef;
132extern "c" const kSecAttrAccessible: CFTypeRef;
133extern "c" const kSecAttrAccessibleWhenUnlockedThisDeviceOnly: CFTypeRef;
134extern "c" const kSecValueData: CFTypeRef;
135extern "c" const kSecReturnData: CFTypeRef;
136extern "c" const kSecMatchLimit: CFTypeRef;
137extern "c" const kSecMatchLimitOne: CFTypeRef;
138extern "c" const kCFBooleanTrue: CFTypeRef;
139
140/// Only the statuses this file *branches* on. Everything else stays unmapped —
141/// a mis-transcribed constant that reclassifies one failure as another is worse
142/// than a generic one — and reaches the log by its raw number.
143const errSecSuccess: OSStatus = 0;
144const errSecItemNotFound: OSStatus = -25300;
145
146/// The action names, spelled exactly as the Swift `case` labels spell them.
147/// `test/ios_conformance_test.zig` matches the two lists by string in both
148/// directions.
149pub const A = struct {
150 pub const secure_set = "secureSet";
151 pub const secure_get = "secureGet";
152 pub const secure_remove = "secureRemove";
153 pub const secure_clear = "secureClear";
154};
155
156/// All four `.result`: each Swift path resolves or rejects a callback, and
157/// each JS surface returns a promise — the legacy ones without a timeout, so
158/// `.none` here would strand a caller forever, not for thirty seconds.
159///
160/// All four `.live`. Every operation is a plain Security.framework call with
161/// no UI, no completion handler and no entitlement requirement (no access
162/// group is ever named), so nothing needs `.unavailable`.
163pub const capability_actions = [_]capabilities.ActionDecl{
164 .{ .name = A.secure_set, .reply = .result },
165 .{ .name = A.secure_get, .reply = .result },
166 .{ .name = A.secure_remove, .reply = .result },
167 .{ .name = A.secure_clear, .reply = .result },
168};
169
170/// Which handler an action selects, split out from `handleMessage` so the
171/// table-versus-dispatch agreement is assertable on a host. That split is
172/// load-bearing for exactly one action here: `secureClear` has no payload and
173/// therefore no validation step, so a test that *called* it to prove routing
174/// would issue a class-wide `SecItemDelete` against the machine running the
175/// tests.
176const Route = enum { set, get, remove, clear };
177
178fn routeFor(action: []const u8) ?Route {
179 if (std.mem.eql(u8, action, A.secure_set)) return .set;
180 if (std.mem.eql(u8, action, A.secure_get)) return .get;
181 if (std.mem.eql(u8, action, A.secure_remove)) return .remove;
182 if (std.mem.eql(u8, action, A.secure_clear)) return .clear;
183 return null;
184}
185
186pub const SecureStoreBridge = struct {
187 allocator: std.mem.Allocator,
188
189 const Self = @This();
190
191 pub fn init(allocator: std.mem.Allocator) Self {
192 return .{ .allocator = allocator };
193 }
194
195 pub fn deinit(_: *Self) void {}
196
197 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
198 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
199 // Exhaustive, so a `Route` without a handler is a compile error.
200 return switch (route) {
201 .set => self.secureSet(data),
202 .get => self.secureGet(data),
203 .remove => self.secureRemove(data),
204 .clear => self.secureClear(),
205 };
206 }
207
208 /// Store `value` under `key`, replacing whatever was there. Resolves
209 /// `true`/`false`, Swift's `resolveCallback(callbackId, result: success)`.
210 fn secureSet(self: *Self, data: []const u8) !void {
211 var parsed = try parsePayload(self.allocator, data);
212 defer parsed.deinit();
213
214 const request = try parseSetRequest(parsed.value);
215 const stored = try keychainStore(self.allocator, request.key, request.value);
216 bridge_error.sendResultToJS(self.allocator, A.secure_set, boolFragment(stored));
217 }
218
219 /// Read `key`. A missing item resolves `null`; a found one resolves the
220 /// bare string. Both are fragments, not objects — `test-bridges.html` does
221 /// `'Stored and retrieved: ' + value` with the reply directly.
222 fn secureGet(self: *Self, data: []const u8) !void {
223 var parsed = try parsePayload(self.allocator, data);
224 defer parsed.deinit();
225
226 const key = try parseKey(parsed.value);
227 const found = try keychainRetrieve(self.allocator, key);
228 defer if (found) |bytes| self.allocator.free(bytes);
229
230 const json = try valueFragment(self.allocator, found);
231 defer self.allocator.free(json);
232 bridge_error.sendResultToJS(self.allocator, A.secure_get, json);
233 }
234
235 /// Delete `key`. Absent resolves `true`, as in Swift — idempotent.
236 fn secureRemove(self: *Self, data: []const u8) !void {
237 var parsed = try parsePayload(self.allocator, data);
238 defer parsed.deinit();
239
240 const key = try parseKey(parsed.value);
241 const removed = try keychainDelete(self.allocator, key);
242 bridge_error.sendResultToJS(self.allocator, A.secure_remove, boolFragment(removed));
243 }
244
245 /// Delete every generic-password item — see the module comment for what
246 /// that includes. The payload is ignored, as Swift ignores the body: the
247 /// only caller is `craft._invoke('secureClear')`, which sends none, and
248 /// `ios_dispatch.payloadOf` hands this `"{}"` for an absent `d`.
249 fn secureClear(self: *Self) !void {
250 const cleared = try keychainClear();
251 bridge_error.sendResultToJS(self.allocator, A.secure_clear, boolFragment(cleared));
252 }
253};
254
255/// Parse `d`, distinguishing a bad payload from a failed allocation — telling
256/// the page INVALID_JSON about its own good JSON sends whoever debugs it to
257/// the wrong side of the bridge.
258fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
259 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
260 error.OutOfMemory => return err,
261 else => return bridge_error.BridgeError.InvalidJSON,
262 };
263}
264
265/// The set payload after validation. Field names are the ones the injected JS
266/// posts (`{action:'secureSet', key: key, value: value, callbackId: id}`) and
267/// the un-migrated shim reads (`body["key"]`, `body["value"]`) — pinned on
268/// both sides of the migration.
269const SetRequest = struct {
270 key: []const u8,
271 value: []const u8,
272};
273
274/// The `key` field, or the reason it cannot be used. Pure, so the host tests
275/// can pin every outcome Swift's `as? String` collapsed into a silent hang.
276fn parseKey(payload: std.json.Value) ![]const u8 {
277 const object = switch (payload) {
278 .object => |o| o,
279 else => return bridge_error.BridgeError.InvalidJSON,
280 };
281 const key_field = object.get("key") orelse return bridge_error.BridgeError.MissingData;
282 const key = switch (key_field) {
283 .string => |s| s,
284 else => return bridge_error.BridgeError.InvalidParameter,
285 };
286 try requireNulFree(key);
287 return key;
288}
289
290fn parseSetRequest(payload: std.json.Value) !SetRequest {
291 const key = try parseKey(payload);
292
293 const object = payload.object; // parseKey proved it is an object
294 const value_field = object.get("value") orelse return bridge_error.BridgeError.MissingData;
295 const value = switch (value_field) {
296 .string => |s| s,
297 // Swift's `as? String` fails here and replies nothing at all. A
298 // coercion would be worse: it would overwrite a real secret with a
299 // stringified something and report success.
300 else => return bridge_error.BridgeError.InvalidParameter,
301 };
302
303 return .{ .key = key, .value = value };
304}
305
306/// Refuse a `key` carrying the one byte it cannot survive. `\u0000` is a legal
307/// JSON escape and `std.json` decodes it to the byte, so a page can reach
308/// this; `createNSString` (`stringWithUTF8String:`) would then truncate and
309/// the item would be filed under a name the caller did not give. The value
310/// needs no equivalent — `makeData` passes bytes and a length, and the NSData
311/// round-trip test below pins that a NUL survives it.
312fn requireNulFree(s: []const u8) !void {
313 if (std.mem.indexOfScalar(u8, s, 0) != null) return bridge_error.BridgeError.InvalidParameter;
314}
315
316/// The bare boolean fragments Swift's `.fragmentsAllowed` produces from
317/// `resolveCallback(callbackId, result: success)`. Static, so the boolean
318/// replies allocate nothing.
319fn boolFragment(ok: bool) []const u8 {
320 return if (ok) "true" else "false";
321}
322
323/// The get reply: a bare JSON string, or the literal `null` for a missing key.
324///
325/// Escaped with `appendJsonEscaped` because the value is page-controlled and
326/// is replayed into JavaScript that `evaluateJavaScript:` parses as source — a
327/// `"` or `\` in a stored secret would otherwise break the page's promise
328/// resolution. `null` and `""` are different answers and stay different: a
329/// stored empty string must read back as `""`.
330fn valueFragment(allocator: std.mem.Allocator, value: ?[]const u8) ![]u8 {
331 var out: std.ArrayListUnmanaged(u8) = .empty;
332 errdefer out.deinit(allocator);
333
334 if (value) |v| {
335 try out.append(allocator, '"');
336 try bridge_error.appendJsonEscaped(allocator, &out, v);
337 try out.append(allocator, '"');
338 } else {
339 try out.appendSlice(allocator, "null");
340 }
341 return out.toOwnedSlice(allocator);
342}
343
344/// Log a keychain failure with the one thing a bare-boolean reply cannot
345/// carry: the raw OSStatus. The `key` names the failing call; the **value is
346/// never logged**, here or anywhere in this file — it is the secret the caller
347/// chose the keychain for.
348fn logStatus(op: []const u8, key: []const u8, status: OSStatus) void {
349 std.log.warn("secure store {s} failed for key '{s}': OSStatus {d}", .{ op, key, status });
350}
351
352/// The query every operation starts from: class + account. No service, no
353/// access group, matching the Swift exactly — see the module comment for why
354/// adding either would strand Swift-written data.
355///
356/// The dictionary is an autoreleased `NSMutableDictionary`, alive for the
357/// run-loop turn the `WKScriptMessageHandler` callback runs inside, handed to
358/// `SecItem*` via toll-free bridging.
359fn newQuery(allocator: std.mem.Allocator, key: []const u8) !Id {
360 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
361
362 const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse return error.ClassNotFound;
363 const sel_dictionary = objc.sel_registerName("dictionary") orelse return error.SelectorNotFound;
364 const query = objc.msgSendId(NSMutableDictionary, sel_dictionary);
365 if (query == null) return error.QueryAllocationFailed;
366
367 try setQueryValue(query, kSecClass, kSecClassGenericPassword);
368
369 // `createNSString` can hand back nil (its `stringWithUTF8String:` returns
370 // nil for bytes it rejects). `std.json` already validated UTF-8 and the
371 // NUL check ran, so this should not fire — but an unchecked nil into
372 // `setObject:forKey:` is an uncatchable NSInvalidArgumentException, so it
373 // is named rather than assumed away.
374 const account = try objc.createNSString(key, allocator);
375 if (account == null) return error.StringCreationFailed;
376 try setQueryValue(query, kSecAttrAccount, account);
377
378 return query;
379}
380
381/// `-[NSMutableDictionary setObject:forKey:]` with the nil checks that keep a
382/// missing Security.framework constant a named error instead of an
383/// uncatchable ObjC exception.
384fn setQueryValue(dict: Id, cf_key: CFTypeRef, value: Id) !void {
385 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
386
387 if (cf_key == null or value == null) return error.MissingKeychainConstant;
388
389 const sel_set = objc.sel_registerName("setObject:forKey:") orelse return error.SelectorNotFound;
390 objc.msgSendVoid2(dict, sel_set, value, cf_key);
391}
392
393/// `+[NSData dataWithBytes:length:]`, autoreleased. The value's bytes as-is:
394/// `std.json` validated them as UTF-8 already, so there is no conversion step
395/// and nothing that can fail the way Swift's `value.data(using: .utf8)!`
396/// force-unwrap could.
397fn makeData(bytes: []const u8) !Id {
398 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
399
400 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
401 const sel_data = objc.sel_registerName("dataWithBytes:length:") orelse return error.SelectorNotFound;
402
403 // The length must be an explicit `c_ulong` (`NSUInteger`): the variadic
404 // msgSend cast takes the argument type from what is passed.
405 const data = objc.msgSendId2(NSData, sel_data, bytes.ptr, @as(c_ulong, @intCast(bytes.len)));
406 if (data == null) return error.NSDataCreationFailed;
407 return data;
408}
409
410/// Store `value` under `key`, replacing any existing item; `false` means the
411/// add failed and the status is in the log.
412///
413/// Delete-then-add as in Swift, but the delete gets the *scoped* query (class
414/// + account) before the value attributes go in — see the module comment. The
415/// delete's status is discarded exactly as Swift discards it: "nothing to
416/// replace" is the common case. The add's status is never discarded.
417fn keychainStore(allocator: std.mem.Allocator, key: []const u8, value: []const u8) !bool {
418 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
419
420 const query = try newQuery(allocator, key);
421
422 _ = SecItemDelete(query);
423
424 try setQueryValue(query, kSecValueData, try makeData(value));
425 // Device-local, never iCloud-synced, unreadable while locked. On the add
426 // only — in a delete or match query it would filter rather than set.
427 try setQueryValue(query, kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly);
428
429 const status = SecItemAdd(query, null);
430 if (status == errSecSuccess) return true;
431 logStatus("set", key, status);
432 return false;
433}
434
435/// Read `key`, returning an owned copy of its bytes, or null when absent.
436///
437/// Copied out because `SecItemCopyMatching`'s result arrives **+1 retained**
438/// — ARC released it for Swift, Zig must not forget to — and the copy lets the
439/// `CFRelease` sit next to the read on every path.
440///
441/// Divergence from Swift, on purpose: Swift folds *every* non-success status
442/// and every non-UTF-8 value into `nil` → the page sees `null`, a claim that
443/// nothing is stored. Only `errSecItemNotFound` earns `null` here; a failed
444/// call or an undecodable item is an error with the diagnostic in the log.
445fn keychainRetrieve(allocator: std.mem.Allocator, key: []const u8) !?[]u8 {
446 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
447
448 const query = try newQuery(allocator, key);
449 // `kCFBooleanTrue`, not an NSNumber built here — Swift's `true` bridges to
450 // the `__NSCFBoolean` singleton.
451 try setQueryValue(query, kSecReturnData, kCFBooleanTrue);
452 try setQueryValue(query, kSecMatchLimit, kSecMatchLimitOne);
453
454 var result: CFTypeRef = null;
455 const status = SecItemCopyMatching(query, &result);
456
457 if (status == errSecItemNotFound) return null;
458 if (status != errSecSuccess) {
459 logStatus("get", key, status);
460 return bridge_error.BridgeError.NativeCallFailed;
461 }
462
463 const data = result orelse {
464 // errSecSuccess with no object should be impossible; if it happens it
465 // is not "nothing stored" and must not be answered as `null`.
466 std.log.warn("secure store get for key '{s}' returned errSecSuccess with no data", .{key});
467 return bridge_error.BridgeError.NativeCallFailed;
468 };
469 defer CFRelease(data);
470
471 // Swift's `result as? Data`: one message send to be sure this is not
472 // something else read as raw bytes.
473 if (!try isData(data)) {
474 std.log.warn("secure store get for key '{s}' returned a non-CFData result", .{key});
475 return bridge_error.BridgeError.NativeCallFailed;
476 }
477
478 const len = try dataLength(data);
479 const bytes: []const u8 = if (len == 0)
480 // `-bytes` is documented to return nil for empty data.
481 &[_]u8{}
482 else
483 (try dataBytes(data) orelse {
484 std.log.warn("secure store get for key '{s}' returned {d} bytes at a nil pointer", .{ key, len });
485 return bridge_error.BridgeError.NativeCallFailed;
486 })[0..len];
487
488 if (!std.unicode.utf8ValidateSlice(bytes)) {
489 // The item exists; only its bytes cannot ride a string-valued API.
490 // Swift resolves `null` here, indistinguishable from "not stored".
491 std.log.warn("secure store item for key '{s}' is not valid UTF-8; this API only returns strings", .{key});
492 return bridge_error.BridgeError.InvalidParameter;
493 }
494
495 return try allocator.dupe(u8, bytes);
496}
497
498/// Delete `key`; absent is `true`, any other failure is `false` with the
499/// status logged — Swift's `status == errSecSuccess || status ==
500/// errSecItemNotFound`, verbatim.
501fn keychainDelete(allocator: std.mem.Allocator, key: []const u8) !bool {
502 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
503
504 const query = try newQuery(allocator, key);
505 const status = SecItemDelete(query);
506 if (status == errSecSuccess or status == errSecItemNotFound) return true;
507 logStatus("remove", key, status);
508 return false;
509}
510
511/// Delete every generic-password item the app can see — the class-only query
512/// is the entire scope, and the module comment names what that includes. An
513/// already-empty store is `true`, as in Swift.
514fn keychainClear() !bool {
515 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
516
517 const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse return error.ClassNotFound;
518 const sel_dictionary = objc.sel_registerName("dictionary") orelse return error.SelectorNotFound;
519 const query = objc.msgSendId(NSMutableDictionary, sel_dictionary);
520 if (query == null) return error.QueryAllocationFailed;
521
522 try setQueryValue(query, kSecClass, kSecClassGenericPassword);
523
524 const status = SecItemDelete(query);
525 if (status == errSecSuccess or status == errSecItemNotFound) return true;
526 logStatus("clear", "*", status);
527 return false;
528}
529
530/// `-[NSObject isKindOfClass:[NSData class]]`; true for the toll-free-bridged
531/// `CFDataRef` the keychain hands back.
532fn isData(obj: Id) !bool {
533 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
534
535 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
536 const sel_kind = objc.sel_registerName("isKindOfClass:") orelse return error.SelectorNotFound;
537 const Fn = *const fn (Id, Id, Id) callconv(.c) bool;
538 const func: Fn = @ptrCast(&objc.objc_msgSend);
539 return func(obj, sel_kind, NSData);
540}
541
542/// `-[NSData length]`. `NSUInteger`, hence `c_ulong`.
543fn dataLength(data: Id) !usize {
544 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
545
546 const sel_length = objc.sel_registerName("length") orelse return error.SelectorNotFound;
547 const Fn = *const fn (Id, Id) callconv(.c) c_ulong;
548 const func: Fn = @ptrCast(&objc.objc_msgSend);
549 return @intCast(func(data, sel_length));
550}
551
552/// `-[NSData bytes]` — not `-UTF8String`, which a `CFDataRef` does not answer;
553/// that typo would be a runtime crash, not a compile error. The pointer is
554/// valid until the data is released, which the caller does after copying.
555fn dataBytes(data: Id) !?[*]const u8 {
556 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
557
558 const sel_bytes = objc.sel_registerName("bytes") orelse return error.SelectorNotFound;
559 const Fn = *const fn (Id, Id) callconv(.c) ?[*]const u8;
560 const func: Fn = @ptrCast(&objc.objc_msgSend);
561 return func(data, sel_bytes);
562}
563
564// =============================================================================
565// Tests — host-only. Everything that decides what the page sees (routing,
566// parsing, fragment shaping) is a pure function beside the Security calls, and
567// the Security calls themselves are never made from here: three of the four
568// handlers fail validation before reaching them, and `secureClear`, which has
569// no validation to fail at, is pinned through `routeFor` alone (invoking it
570// would address the login keychain of whatever machine runs the tests).
571// =============================================================================
572
573const testing = std.testing;
574
575test "the declared actions are the ones the handler serves" {
576 try testing.expectEqual(@as(usize, 4), capability_actions.len);
577 try testing.expectEqualStrings(A.secure_set, capability_actions[0].name);
578 try testing.expectEqualStrings(A.secure_get, capability_actions[1].name);
579 try testing.expectEqualStrings(A.secure_remove, capability_actions[2].name);
580 try testing.expectEqualStrings(A.secure_clear, capability_actions[3].name);
581
582 for (capability_actions) |decl| {
583 try testing.expectEqual(capabilities.Reply.result, decl.reply);
584 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
585 }
586}
587
588test "the action names match the Swift case labels exactly" {
589 // The conformance ratchet compares these against the `case "…":` labels in
590 // `CraftApp.swift` in both directions; a prettier spelling would read as
591 // Zig serving an action the spec does not have.
592 try testing.expectEqualStrings("secureSet", A.secure_set);
593 try testing.expectEqualStrings("secureGet", A.secure_get);
594 try testing.expectEqualStrings("secureRemove", A.secure_remove);
595 try testing.expectEqualStrings("secureClear", A.secure_clear);
596}
597
598test "every declared action is one the dispatcher routes" {
599 for (capability_actions) |decl| {
600 if (routeFor(decl.name) == null) {
601 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
602 return error.DeclaredActionDoesNotRoute;
603 }
604 }
605}
606
607test "every route the dispatcher has is a declared action" {
608 // The other direction: a route with a handler and no declaration is an
609 // action the page can call and the manifest denies exists. Each
610 // declaration must claim a *distinct* route — counting alone would let two
611 // rows share one route while another went undeclared.
612 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
613 for (capability_actions) |decl| {
614 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
615 const slot = @backingInt(route);
616 if (claimed[slot]) {
617 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
618 return error.TwoDeclarationsShareARoute;
619 }
620 claimed[slot] = true;
621 }
622 for (claimed, 0..) |taken, slot| {
623 if (!taken) {
624 std.debug.print(
625 "route {s} has a handler but no capability_actions row\n",
626 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
627 );
628 return error.RouteNotDeclared;
629 }
630 }
631}
632
633test "the payload-taking actions fail validation before any Security call" {
634 // `{}` has no `key`, so set/get/remove error out before the keychain is
635 // touched — which is what makes this safe on a developer's machine, and
636 // what rules out a routed name whose handler ignores its payload contract.
637 // `secureClear` is deliberately absent: it has no payload and no
638 // validation gate, so invoking it here would be a real class-wide delete.
639 var bridge = SecureStoreBridge.init(testing.allocator);
640 defer bridge.deinit();
641
642 const payload_actions = [_][]const u8{ A.secure_set, A.secure_get, A.secure_remove };
643 for (payload_actions) |name| {
644 try testing.expectError(
645 bridge_error.BridgeError.MissingData,
646 bridge.handleMessage(name, "{}"),
647 );
648 }
649}
650
651test "an action the namespace does not serve is reported, not ignored" {
652 var bridge = SecureStoreBridge.init(testing.allocator);
653 defer bridge.deinit();
654
655 try testing.expectError(
656 bridge_error.BridgeError.UnknownAction,
657 bridge.handleMessage("noSuchAction", "{}"),
658 );
659 // Near misses — casing is how a real typo arrives.
660 try testing.expectError(
661 bridge_error.BridgeError.UnknownAction,
662 bridge.handleMessage("secureset", "{}"),
663 );
664 // The JS surface names, which are not action names.
665 try testing.expectError(
666 bridge_error.BridgeError.UnknownAction,
667 bridge.handleMessage("secureStore", "{}"),
668 );
669 try testing.expectError(
670 bridge_error.BridgeError.UnknownAction,
671 bridge.handleMessage("secureStorage", "{}"),
672 );
673 // The sharedItems namespace next door shares the *keychain* with this
674 // module but must not share the dispatch: two modules claiming one action
675 // would make `ios_dispatch`'s first-match routing order-dependent.
676 try testing.expectError(
677 bridge_error.BridgeError.UnknownAction,
678 bridge.handleMessage("setSharedItem", "{\"key\":\"k\",\"value\":\"v\"}"),
679 );
680}
681
682test "a malformed payload is reported as bad JSON, not as a missing field" {
683 var bridge = SecureStoreBridge.init(testing.allocator);
684 defer bridge.deinit();
685
686 try testing.expectError(
687 bridge_error.BridgeError.InvalidJSON,
688 bridge.handleMessage(A.secure_get, "{not json"),
689 );
690}
691
692fn expectKeyError(json: []const u8, expected: anyerror) !void {
693 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
694 defer parsed.deinit();
695 try testing.expectError(expected, parseKey(parsed.value));
696}
697
698fn expectSetError(json: []const u8, expected: anyerror) !void {
699 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
700 defer parsed.deinit();
701 try testing.expectError(expected, parseSetRequest(parsed.value));
702}
703
704test "the field names the page sends are the ones that are read" {
705 // `{action:'secureSet', key: key, value: value, callbackId: id}` in the
706 // injected JS; the shim reads `body["key"]` / `body["value"]`. A rename on
707 // either side of the migration would make the two handlers read different
708 // payloads.
709 var parsed = try std.json.parseFromSlice(
710 std.json.Value,
711 testing.allocator,
712 "{\"key\":\"token\",\"value\":\"abc123\"}",
713 .{},
714 );
715 defer parsed.deinit();
716
717 const request = try parseSetRequest(parsed.value);
718 try testing.expectEqualStrings("token", request.key);
719 try testing.expectEqualStrings("abc123", request.value);
720 try testing.expectEqualStrings("token", try parseKey(parsed.value));
721}
722
723test "an absent key is refused rather than defaulted" {
724 try expectKeyError("{}", bridge_error.BridgeError.MissingData);
725 try expectSetError("{\"value\":\"v\"}", bridge_error.BridgeError.MissingData);
726}
727
728test "an absent value is refused rather than stored as empty" {
729 // Swift's `if let value = body["value"] as? String` fails here and replies
730 // nothing at all — the legacy promise has no timeout, so that is a hang. A
731 // default of `""` would be worse: it would overwrite a real secret and
732 // resolve `true`.
733 try expectSetError("{\"key\":\"k\"}", bridge_error.BridgeError.MissingData);
734}
735
736test "a non-string key or value is refused, not coerced" {
737 try expectKeyError("{\"key\":7}", bridge_error.BridgeError.InvalidParameter);
738 try expectKeyError("{\"key\":null}", bridge_error.BridgeError.InvalidParameter);
739 try expectSetError("{\"key\":\"k\",\"value\":7}", bridge_error.BridgeError.InvalidParameter);
740 try expectSetError("{\"key\":\"k\",\"value\":null}", bridge_error.BridgeError.InvalidParameter);
741}
742
743test "an explicitly empty value is a value, and is stored" {
744 var parsed = try std.json.parseFromSlice(
745 std.json.Value,
746 testing.allocator,
747 "{\"key\":\"k\",\"value\":\"\"}",
748 .{},
749 );
750 defer parsed.deinit();
751 const request = try parseSetRequest(parsed.value);
752 try testing.expectEqualStrings("", request.value);
753}
754
755test "a key with an embedded NUL is refused, not truncated" {
756 // `stringWithUTF8String:` stops at the first NUL, so an unchecked
757 // "a\u0000b" would address the item named "a" while the reply reported on
758 // the full key — and `secureGet("a")` would then hand back a secret filed
759 // under a different page key.
760 try expectKeyError("{\"key\":\"a\\u0000b\"}", bridge_error.BridgeError.InvalidParameter);
761 try expectSetError(
762 "{\"key\":\"a\\u0000b\",\"value\":\"v\"}",
763 bridge_error.BridgeError.InvalidParameter,
764 );
765
766 // The *value* keeps its NUL: `makeData` passes bytes and a length, so
767 // nothing truncates, and the NSData round-trip test below proves it.
768 // Refusing it would drop a payload the handler carries fine — and Swift
769 // stores it whole too, so this is the compatible answer, not the lenient
770 // one.
771 var parsed = try std.json.parseFromSlice(
772 std.json.Value,
773 testing.allocator,
774 "{\"key\":\"k\",\"value\":\"a\\u0000b\"}",
775 .{},
776 );
777 defer parsed.deinit();
778 const request = try parseSetRequest(parsed.value);
779 try testing.expectEqualSlices(u8, &[_]u8{ 'a', 0, 'b' }, request.value);
780}
781
782test "a payload that is not an object is bad JSON, not a missing field" {
783 try expectKeyError("[]", bridge_error.BridgeError.InvalidJSON);
784 try expectKeyError("\"key\"", bridge_error.BridgeError.InvalidJSON);
785}
786
787test "the boolean replies are the two bare fragments and nothing else" {
788 // Swift resolves with `.fragmentsAllowed`, so the page receives the JSON
789 // fragment `true` or `false` — not `{"success":true}`, which is the shape
790 // next door in sharedItems and would break `craft.secureStore.set`'s
791 // boolean contract.
792 try testing.expectEqualStrings("true", boolFragment(true));
793 try testing.expectEqualStrings("false", boolFragment(false));
794}
795
796test "a found value and a missing one are different fragments" {
797 const found = try valueFragment(testing.allocator, "abc123");
798 defer testing.allocator.free(found);
799 try testing.expectEqualStrings("\"abc123\"", found);
800
801 // A nil String? cast `as Any` bridges to NSNull, which serializes as the
802 // fragment `null` — the promise *resolves* with null, it does not reject.
803 const missing = try valueFragment(testing.allocator, null);
804 defer testing.allocator.free(missing);
805 try testing.expectEqualStrings("null", missing);
806
807 // A stored empty string is not a missing item.
808 const empty = try valueFragment(testing.allocator, "");
809 defer testing.allocator.free(empty);
810 try testing.expectEqualStrings("\"\"", empty);
811}
812
813test "a page-controlled value cannot break the reply" {
814 // The fragment is replayed into JavaScript that `evaluateJavaScript:`
815 // parses as source; an unescaped quote in a stored secret would be a
816 // syntax error in the page. The NUL matters too: a value stored with one
817 // must survive the round trip through the JSON escape.
818 const json = try valueFragment(testing.allocator, "we\"ird\\secret\nwith\x00nul");
819 defer testing.allocator.free(json);
820
821 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
822 defer parsed.deinit();
823 try testing.expectEqualStrings("we\"ird\\secret\nwith\x00nul", parsed.value.string);
824}
825
826test "the constants that are branched on are the documented ones" {
827 // `errSecItemNotFound` is the difference between `null`/`true` and a
828 // failure on three of the four actions; a wrong `errSecSuccess` would make
829 // every call look failed.
830 try testing.expectEqual(@as(OSStatus, 0), errSecSuccess);
831 try testing.expectEqual(@as(OSStatus, -25300), errSecItemNotFound);
832}
833
834// The remaining tests exercise the Objective-C half against the live runtime —
835// libobjc and Foundation only, no keychain, skipped off Darwin. They exist
836// because a wrong selector string is a runtime crash or a silent no-op, never
837// a compile error, and these are this file's own copies of the selectors.
838
839test "the NSData selectors round-trip bytes, embedded NUL included" {
840 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
841
842 // The NUL is the point: `length`/`bytes` rather than `UTF8String`, which
843 // would truncate a stored secret at the first zero byte and report the
844 // truncation as the value.
845 const payload = "se\x00cret";
846 const data = try makeData(payload);
847 try testing.expect(try isData(data));
848
849 const len = try dataLength(data);
850 try testing.expectEqual(@as(usize, payload.len), len);
851
852 const bytes = (try dataBytes(data)).?;
853 try testing.expectEqualSlices(u8, payload, bytes[0..len]);
854}
855
856test "an empty value is empty data, and is never dereferenced" {
857 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
858
859 const data = try makeData("");
860 try testing.expect(try isData(data));
861 try testing.expectEqual(@as(usize, 0), try dataLength(data));
862}
863
864test "the CFData type check rejects something that is not data" {
865 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
866
867 // Stands in for "the keychain returned something else" — the case that
868 // must not be read as raw bytes and handed to the page as a secret.
869 const ns = try objc.createNSString("not data", testing.allocator);
870 try testing.expect(!try isData(ns));
871}
872
873test "the query builder produces a dictionary the runtime accepts" {
874 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
875
876 // Proves the `kSec*` globals resolve, `NSMutableDictionary` allocates, and
877 // `setObject:forKey:`'s selector is spelled right — without a single
878 // `SecItem*` call, so the host keychain is never touched. A headless CI
879 // runner may have no login keychain at all, and a test depending on one is
880 // a flake, not a gate; the write/read/delete round trip needs the
881 // simulator fixture.
882 const query = try newQuery(testing.allocator, "conformance-probe");
883 try testing.expect(query != null);
884}
packages/zig/src/bridge_mobile_shortcuts.zigadded+949-0
Changes to packages/zig/src/bridge_mobile_shortcuts.zig
@@ -0,0 +1,949 @@
1//! The home-screen quick-action pair of the `mobile` namespace:
2//! `setShortcuts`, `clearShortcuts`.
3//!
4//! These are `UIApplication.shortcutItems` — the long-press menu on the app
5//! icon — not the desktop hotkeys of `bridge_shortcuts.zig`, which shares
6//! nothing with this file but a word. Both actions are one property write on
7//! the shared application, done synchronously: the Swift helpers wrap the
8//! write in `DispatchQueue.main.async`, but the Zig dispatcher is already
9//! inside the `WKScriptMessageHandler` callback on the main thread, so the
10//! hop would only move the reply away from the frame that holds this call's
11//! id. No completion handler exists anywhere on either path, hence no
12//! `ios_async` ticket.
13//!
14//! ## What is carried across exactly, because it is the observable contract
15//!
16//! - **`setShortcuts` resolves with the object `{"count":N}`** — the Swift
17//! dictionary literal — and `clearShortcuts` resolves with the **bare JSON
18//! fragment `true`** (Swift serialises with `.fragmentsAllowed`, and
19//! `test-bridges.html` renders `'Shortcuts cleared: ' + result`, so the
20//! bare boolean is load-bearing).
21//! - **An empty `shortcuts` array is a valid request**: it installs an empty
22//! list and replies `{"count":0}`, exactly as Swift does. It is not folded
23//! into `clearShortcuts`.
24//! - **A JSON `null` `subtitle`/`iconName`/`userInfo` means "none"**, the
25//! same answer as omitting the field — Swift's `as? String` turns `NSNull`
26//! into `nil`, and `JSON.stringify` keeps an explicit null where it drops
27//! an undefined.
28//!
29//! ## What is deliberately not carried across
30//!
31//! **Swift's silent hang.** The dispatcher arm is `if let shortcuts = ... as?
32//! [[String: Any]]` with no `else`: a missing or non-array `shortcuts` replies
33//! nothing and the page's promise never settles. Every path here ends in a
34//! reply or an error, per the precedent `bridge_mobile_storage.zig` set.
35//!
36//! **Swift's silent element skip.** An element missing `type` or `title` is
37//! `continue`d and the reply reports a smaller `count`. Skipping is dropping a
38//! payload element under a success, so a malformed element refuses the whole
39//! batch with `InvalidParameter` instead — which also means `count` always
40//! equals the length of the array the page sent, never a quiet subset. The
41//! same goes for a `subtitle`/`iconName`/`userInfo` of the wrong type, which
42//! Swift's `as?` casts would drop while installing the rest of the shortcut.
43//!
44//! **A NUL byte in `type`, `title`, `subtitle` or `iconName`.** All four reach
45//! UIKit through `stringWithUTF8String:`, which stops at the first NUL, and
46//! `type` in particular is the identity the app would match on activation — a
47//! truncated one is a different shortcut than the page named, reported as
48//! installed. Refused with `InvalidParameter`; see `requireNulFree`.
49//! `userInfo` string values are exempt: they travel as escaped JSON bytes
50//! through `NSJSONSerialization` and keep their NULs whole.
51//!
52//! ## `userInfo` is carried, not dropped
53//!
54//! Nothing in the repo sends it, but the injected JS contract permits it and
55//! Swift stores it, so silently discarding it here would be the exact
56//! dropped-field bug this migration exists to remove. The element's sub-JSON
57//! is re-serialised and handed to `NSJSONSerialization`, which yields the same
58//! Foundation types (`NSString`/`NSNumber`/`NSArray`/`NSDictionary`/`NSNull`)
59//! that Swift's `as? [String: NSSecureCoding]` cast admits.
60//!
61//! ## The gap that is real and is not this file's to close
62//!
63//! Installing shortcuts is honest — they appear in the long-press menu — but
64//! **tapping one never reaches the page**: the injected JS registers
65//! `craftShortcut` listeners, and no template implements
66//! `application(_:performActionFor:completionHandler:)` or any scene-delegate
67//! equivalent, so nothing ever dispatches that event. Tapping launches or
68//! foregrounds the app and stops there. Pre-existing, identical under the
69//! Swift shim, and outside these two actions' scope.
70
71const std = ;
72const builtin = ;
73const capabilities = ;
74const bridge_error = ;
75const objc_runtime = ;
76
77const objc = objc_runtime.objc;
78
79/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
80///
81/// `objc_runtime.objc` is an empty struct off Darwin, and a function
82/// *signature* is analysed even when a comptime platform guard makes its body
83/// unreachable, so naming `objc.id` in module-level signatures would break the
84/// host build. It stays a single optional pointer, never `?objc.id`: a double
85/// optional is illegal in a `callconv(.c)` type.
86const Id = ?*anyopaque;
87
88/// The action names, spelled exactly as the Swift `case` labels spell them.
89/// The conformance ratchet matches the two lists by string in both directions.
90pub const A = struct {
91 pub const set_shortcuts = "setShortcuts";
92 pub const clear_shortcuts = "clearShortcuts";
93};
94
95/// Both `.result`: each Swift path terminates in exactly one
96/// `resolveCallback`, and both injected JS methods return promises the page
97/// awaits. `clearShortcuts` resolving with a bare fragment is still a result —
98/// `.none` would tell an app the action is fire-and-forget and strand the
99/// caller for the whole request timeout.
100///
101/// Both `.live`: everything each action does is one main-thread property
102/// write, fully reachable from Zig.
103pub const capability_actions = [_]capabilities.ActionDecl{
104 .{ .name = A.set_shortcuts, .reply = .result },
105 .{ .name = A.clear_shortcuts, .reply = .result },
106};
107
108/// One shortcut, after validation. Slices borrow from the parsed payload,
109/// which outlives every use — the whole handler runs inside one dispatch.
110///
111/// The field is `@"type"` and not something prettier because the wire name is
112/// the contract: the injected JS posts `type`, the Swift shim reads
113/// `shortcut["type"]`, and a rename here is exactly the field-name drift that
114/// broke `craft.fs.writeFile`.
115const Shortcut = struct {
116 type: []const u8,
117 title: []const u8,
118 subtitle: ?[]const u8 = null,
119 icon_name: ?[]const u8 = null,
120 /// Kept as the parsed JSON value; converted to Foundation objects only on
121 /// Darwin, at install time. Always `.object` when non-null.
122 user_info: ?std.json.Value = null,
123};
124
125/// Which handler an action selects, or null for one this namespace does not
126/// serve. Split out from `handleMessage` so the table-versus-dispatch
127/// agreement can be asserted on a host, where `UIApplication` does not exist.
128const Route = enum { set_shortcuts, clear_shortcuts };
129
130fn routeFor(action: []const u8) ?Route {
131 if (std.mem.eql(u8, action, A.set_shortcuts)) return .set_shortcuts;
132 if (std.mem.eql(u8, action, A.clear_shortcuts)) return .clear_shortcuts;
133 return null;
134}
135
136pub const ShortcutsBridge = struct {
137 allocator: std.mem.Allocator,
138
139 const Self = @This();
140
141 pub fn init(allocator: std.mem.Allocator) Self {
142 return .{ .allocator = allocator };
143 }
144
145 pub fn deinit(_: *Self) void {}
146
147 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
148 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
149 // Exhaustive, so a `Route` without a handler is a compile error.
150 return switch (route) {
151 .set_shortcuts => self.setShortcuts(data),
152 .clear_shortcuts => self.clearShortcuts(),
153 };
154 }
155
156 /// Replace the app's quick actions with the batch the page sent.
157 fn setShortcuts(self: *Self, data: []const u8) !void {
158 var parsed = try parsePayload(self.allocator, data);
159 defer parsed.deinit();
160
161 const shortcuts = try parseShortcuts(self.allocator, parsed.value);
162 defer self.allocator.free(shortcuts);
163
164 try installShortcuts(self.allocator, shortcuts);
165
166 // Counted from what Zig validated and installed, never read back from
167 // `shortcutItems` — Swift counts its own local array the same way. The
168 // whole batch installs or nothing does, so this is always the length
169 // the page sent.
170 var buf: [40]u8 = undefined;
171 const json = try countReply(&buf, shortcuts.len);
172 bridge_error.sendResultToJS(self.allocator, A.set_shortcuts, json);
173 }
174
175 /// Remove all quick actions.
176 ///
177 /// `data` is deliberately not parsed: the injected JS posts no payload
178 /// (`ios_dispatch.payloadOf` normalises the absent `d` to `"{}"`), Swift
179 /// dispatches unconditionally, and there is no field whose absence or
180 /// shape could change what "clear" means.
181 fn clearShortcuts(self: *Self) !void {
182 try uninstallShortcuts();
183 bridge_error.sendResultToJS(self.allocator, A.clear_shortcuts, clear_reply);
184 }
185};
186
187/// The `clearShortcuts` resolution: Swift's `resolveCallback(_, result: true)`
188/// under `.fragmentsAllowed` — a bare boolean, not `{"success":true}`. The
189/// page string-concatenates the resolved value, so the fragment is the shape.
190const clear_reply = "true";
191
192/// Parse `d`, distinguishing a bad payload from a failed allocation —
193/// `OutOfMemory` propagates as itself so nobody debugs their own good JSON.
194fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
195 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
196 error.OutOfMemory => return err,
197 else => return bridge_error.BridgeError.InvalidJSON,
198 };
199}
200
201/// The validated batch, or the reason it cannot be installed. Pure, so the
202/// host tests can pin every outcome Swift collapsed into a silent hang, a
203/// silent skip, or a silent `as?`-cast drop.
204///
205/// A missing `shortcuts` is `MissingData` (the required field is absent), a
206/// present-but-wrong one — including JSON `null` — is `InvalidParameter`,
207/// matching `bridge_mobile_storage.zig`'s split. Swift replies *nothing* in
208/// both cases and the page hangs; the divergence is the module's headline.
209fn parseShortcuts(allocator: std.mem.Allocator, payload: std.json.Value) ![]Shortcut {
210 const object = switch (payload) {
211 .object => |o| o,
212 else => return bridge_error.BridgeError.InvalidJSON,
213 };
214
215 const field = object.get("shortcuts") orelse return bridge_error.BridgeError.MissingData;
216 const array = switch (field) {
217 .array => |a| a,
218 else => return bridge_error.BridgeError.InvalidParameter,
219 };
220
221 var out: std.ArrayListUnmanaged(Shortcut) = .empty;
222 errdefer out.deinit(allocator);
223 for (array.items) |element| {
224 try out.append(allocator, try parseShortcut(element));
225 }
226 return out.toOwnedSlice(allocator);
227}
228
229/// One element. Everything wrong with it is `InvalidParameter` — the
230/// malformed thing is an entry inside the `shortcuts` argument, not a missing
231/// top-level field — and it refuses the batch rather than being skipped; the
232/// module comment carries the argument.
233fn parseShortcut(element: std.json.Value) !Shortcut {
234 const object = switch (element) {
235 .object => |o| o,
236 else => return bridge_error.BridgeError.InvalidParameter,
237 };
238
239 var shortcut = Shortcut{
240 .type = try requiredString(object, "type"),
241 .title = try requiredString(object, "title"),
242 };
243 shortcut.subtitle = try optionalString(object, "subtitle");
244 shortcut.icon_name = try optionalString(object, "iconName");
245
246 if (object.get("userInfo")) |info| {
247 shortcut.user_info = switch (info) {
248 .null => null,
249 // Kept whole; strings inside may carry NULs, which the
250 // NSJSONSerialization route preserves — no `requireNulFree` here.
251 .object => info,
252 // Swift's `as? [String: NSSecureCoding]` turns anything else into
253 // nil and installs the shortcut without it, reported as success.
254 else => return bridge_error.BridgeError.InvalidParameter,
255 };
256 }
257
258 return shortcut;
259}
260
261/// A field Swift `guard`s on: present and a string, or the batch is refused.
262fn requiredString(object: std.json.ObjectMap, name: []const u8) ![]const u8 {
263 const field = object.get(name) orelse return bridge_error.BridgeError.InvalidParameter;
264 const s = switch (field) {
265 .string => |v| v,
266 else => return bridge_error.BridgeError.InvalidParameter,
267 };
268 try requireNulFree(s);
269 return s;
270}
271
272/// A field Swift reads with `as? String`: absent and JSON `null` both mean
273/// "none"; any other non-string is refused rather than dropped.
274fn optionalString(object: std.json.ObjectMap, name: []const u8) !?[]const u8 {
275 const field = object.get(name) orelse return null;
276 return switch (field) {
277 .null => null,
278 .string => |v| blk: {
279 try requireNulFree(v);
280 break :blk v;
281 },
282 else => bridge_error.BridgeError.InvalidParameter,
283 };
284}
285
286/// Refuse a string carrying the one byte `stringWithUTF8String:` cannot
287/// survive. `\u0000` is a legal JSON escape and `std.json` decodes it to the
288/// byte, so a page can reach this. Without the check, a `type` of
289/// `"open\u0000debug"` would install the shortcut whose activation identity is
290/// `"open"` and reply `{"count":1}` as if the asked-for one existed.
291fn requireNulFree(s: []const u8) !void {
292 if (std.mem.indexOfScalar(u8, s, 0) != null) return bridge_error.BridgeError.InvalidParameter;
293}
294
295/// `{"count":N}` — the Swift dictionary literal, verbatim. `bufPrint` is safe
296/// here where storage needed escaping: nothing page-controlled is echoed.
297fn countReply(buf: []u8, count: usize) ![]const u8 {
298 return std.fmt.bufPrint(buf, "", .{count});
299}
300
301/// Re-serialise a parsed value to JSON bytes for `NSJSONSerialization`.
302///
303/// `std.json.Value` does not remember its source span, so this walk exists.
304/// Strings and keys go through `bridge_error.appendJsonEscaped`, which escapes
305/// every control byte — `bridge_handoff.zig`'s older copy of this walk misses
306/// NUL, and an unescaped control byte makes the bytes invalid JSON that
307/// Foundation would refuse, turning a legal payload into a refusal.
308///
309/// A non-finite float is refused *here*, purely and testably: `{d}` would
310/// render `inf`, which is not JSON, and the failure would otherwise surface as
311/// an opaque nil from Foundation.
312fn serializeJsonValue(
313 allocator: std.mem.Allocator,
314 out: *std.ArrayListUnmanaged(u8),
315 value: std.json.Value,
316) !void {
317 switch (value) {
318 .null => try out.appendSlice(allocator, "null"),
319 .bool => |b| try out.appendSlice(allocator, if (b) "true" else "false"),
320 .integer => |i| {
321 var buf: [24]u8 = undefined;
322 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{i}));
323 },
324 .float => |f| {
325 if (!std.math.isFinite(f)) return bridge_error.BridgeError.InvalidParameter;
326 // `{d}` renders decimal notation, never scientific, and a finite
327 // f64 can need up to `bufferSize(.decimal, f64)` (347) bytes —
328 // 1e300 alone is 301 digits. A smaller buffer turns that legal
329 // JSON number into a NoSpaceLeft refusal.
330 var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
331 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{f}));
332 },
333 // Only produced under parse options this module does not use, but
334 // carrying it costs one line and dropping it would be a silent hole.
335 .number_string => |s| try out.appendSlice(allocator, s),
336 .string => |s| {
337 try out.append(allocator, '"');
338 try bridge_error.appendJsonEscaped(allocator, out, s);
339 try out.append(allocator, '"');
340 },
341 .array => |items| {
342 try out.append(allocator, '[');
343 for (items.items, 0..) |item, i| {
344 if (i > 0) try out.append(allocator, ',');
345 try serializeJsonValue(allocator, out, item);
346 }
347 try out.append(allocator, ']');
348 },
349 .object => |obj| {
350 try out.append(allocator, '{');
351 var it = obj.iterator();
352 var first = true;
353 while (it.next()) |entry| {
354 if (!first) try out.append(allocator, ',');
355 first = false;
356 try out.append(allocator, '"');
357 try bridge_error.appendJsonEscaped(allocator, out, entry.key_ptr.*);
358 try out.appendSlice(allocator, "\":");
359 try serializeJsonValue(allocator, out, entry.value_ptr.*);
360 }
361 try out.append(allocator, '}');
362 },
363 }
364}
365
366// =============================================================================
367// The Objective-C half. Main thread throughout — see the module comment.
368// =============================================================================
369
370/// `[UIApplication sharedApplication]`, non-nil or an error.
371///
372/// Resolved before anything is built so a process with no shared application —
373/// an app extension, or one that has not reached `UIApplicationMain` — fails
374/// early instead of assembling items nothing can install.
375fn sharedApplication() !*anyopaque {
376 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
377
378 const UIApplication = objc.objc_getClass("UIApplication") orelse return error.ClassNotFound;
379 const sel_shared = objc.sel_registerName("sharedApplication") orelse return error.SelectorNotFound;
380 return objc.msgSendId(UIApplication, sel_shared) orelse error.NoSharedApplication;
381}
382
383/// Build the item array and assign it. All-or-nothing: any element that fails
384/// aborts before `setShortcutItems:` is reached, leaving the previous set
385/// installed — never a partial batch reported whole.
386fn installShortcuts(allocator: std.mem.Allocator, shortcuts: []const Shortcut) !void {
387 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
388
389 const app = try sharedApplication();
390
391 const NSMutableArray = objc.objc_getClass("NSMutableArray") orelse return error.ClassNotFound;
392 const sel_array = objc.sel_registerName("array") orelse return error.SelectorNotFound;
393 const sel_add = objc.sel_registerName("addObject:") orelse return error.SelectorNotFound;
394 // Autoreleased; on the error paths below the pool drains it, items and
395 // all, at the end of this run-loop turn.
396 const items = objc.msgSendId(NSMutableArray, sel_array);
397 if (items == null) return error.NativeCallFailed;
398
399 for (shortcuts) |shortcut| {
400 // Non-null by construction — `addObject:` raises an uncatchable
401 // NSInvalidArgumentException on nil, which is why `makeShortcutItem`
402 // returns `*anyopaque` and not `Id`.
403 const item = try makeShortcutItem(allocator, shortcut);
404 objc.msgSendVoid1(items, sel_add, item);
405 // `alloc`/`init` handed it over at +1; the array's retain is now the
406 // one keeping it alive. Without this, one item leaks per shortcut per
407 // call.
408 objc.release(item);
409 }
410
411 const sel_set = objc.sel_registerName("setShortcutItems:") orelse return error.SelectorNotFound;
412 objc.msgSendVoid1(app, sel_set, items);
413}
414
415/// `UIApplication.shared.shortcutItems = nil`, which is how "no shortcuts" is
416/// spelled — distinct from assigning an empty array, though UIKit renders both
417/// as an empty menu.
418fn uninstallShortcuts() !void {
419 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
420
421 const app = try sharedApplication();
422 const sel_set = objc.sel_registerName("setShortcutItems:") orelse return error.SelectorNotFound;
423 objc.msgSendVoid1(app, sel_set, @as(Id, null));
424}
425
426/// One `UIApplicationShortcutItem`, at +1 and never nil.
427///
428/// `initWithType:localizedTitle:...` declares `type` and `localizedTitle`
429/// nonnull and raises on a nil — so the `createNSString` results are checked
430/// even though `std.json` has already validated the bytes as UTF-8, because a
431/// nil into that initialiser is an uncatchable crash, not an error.
432fn makeShortcutItem(allocator: std.mem.Allocator, shortcut: Shortcut) !*anyopaque {
433 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
434
435 const ns_type = try objc.createNSString(shortcut.type, allocator);
436 if (ns_type == null) return bridge_error.BridgeError.InvalidParameter;
437 const ns_title = try objc.createNSString(shortcut.title, allocator);
438 if (ns_title == null) return bridge_error.BridgeError.InvalidParameter;
439
440 var ns_subtitle: Id = null;
441 if (shortcut.subtitle) |subtitle| {
442 ns_subtitle = try objc.createNSString(subtitle, allocator);
443 if (ns_subtitle == null) return bridge_error.BridgeError.InvalidParameter;
444 }
445
446 var icon: Id = null;
447 if (shortcut.icon_name) |name| icon = try makeIcon(allocator, name);
448
449 var user_info: Id = null;
450 if (shortcut.user_info) |info| user_info = try toFoundationObject(allocator, info);
451
452 const UIApplicationShortcutItem = objc.objc_getClass("UIApplicationShortcutItem") orelse
453 return error.ClassNotFound;
454 // Registered before `alloc` so a failed lookup cannot leak the +1 object.
455 const sel_init = objc.sel_registerName("initWithType:localizedTitle:localizedSubtitle:icon:userInfo:") orelse
456 return error.SelectorNotFound;
457
458 const allocated = try objc.alloc(UIApplicationShortcutItem);
459 if (allocated == null) return error.NativeCallFailed;
460
461 // subtitle, icon and userInfo are declared nullable — nil is the "none"
462 // the page asked for, not a missing argument.
463 const InitFn = *const fn (Id, objc.SEL, Id, Id, Id, Id, Id) callconv(.c) Id;
464 const init_fn: InitFn = @ptrCast(&objc.objc_msgSend);
465 const item = init_fn(allocated, sel_init, ns_type, ns_title, ns_subtitle, icon, user_info);
466 // A nil from init means init consumed the allocation (the ObjC
467 // convention), so there is nothing to release here.
468 return item orelse error.NativeCallFailed;
469}
470
471/// `+[UIApplicationShortcutIcon iconWithSystemImageName:]` (iOS 13+),
472/// autoreleased.
473///
474/// The honesty limit: UIKit returns a real icon object even for a bogus SF
475/// Symbols name — it just renders blank — and exposes no validation API, so a
476/// typo'd `iconName` cannot be caught here by anyone. The nil check guards the
477/// call convention, not the name.
478fn makeIcon(allocator: std.mem.Allocator, name: []const u8) !*anyopaque {
479 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
480
481 const UIApplicationShortcutIcon = objc.objc_getClass("UIApplicationShortcutIcon") orelse
482 return error.ClassNotFound;
483 const sel_icon = objc.sel_registerName("iconWithSystemImageName:") orelse return error.SelectorNotFound;
484
485 const ns_name = try objc.createNSString(name, allocator);
486 if (ns_name == null) return bridge_error.BridgeError.InvalidParameter;
487
488 return objc.msgSendId1(UIApplicationShortcutIcon, sel_icon, ns_name) orelse error.NativeCallFailed;
489}
490
491/// A parsed JSON object as the Foundation containers Swift's
492/// `as? [String: NSSecureCoding]` admits, via
493/// `+[NSJSONSerialization JSONObjectWithData:options:error:]` (autoreleased).
494///
495/// The round trip through bytes is the honest route: NSString keys and values
496/// built this way keep embedded NULs that `stringWithUTF8String:` would
497/// truncate, and every Foundation type it can produce conforms to
498/// NSSecureCoding. A nil back is `InvalidParameter` — the caller's `userInfo`
499/// holds something JSON cannot carry — with the log naming the field, since
500/// the error code alone cannot.
501fn toFoundationObject(allocator: std.mem.Allocator, value: std.json.Value) !*anyopaque {
502 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
503
504 var bytes: std.ArrayListUnmanaged(u8) = .empty;
505 defer bytes.deinit(allocator);
506 try serializeJsonValue(allocator, &bytes, value);
507
508 const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound;
509 const sel_data = objc.sel_registerName("dataWithBytes:length:") orelse return error.SelectorNotFound;
510 const data = objc.msgSendId2(NSData, sel_data, bytes.items.ptr, @as(c_ulong, @intCast(bytes.items.len)));
511 if (data == null) return error.NativeCallFailed;
512
513 const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse return error.ClassNotFound;
514 const sel_parse = objc.sel_registerName("JSONObjectWithData:options:error:") orelse
515 return error.SelectorNotFound;
516 const ParseFn = *const fn (Id, objc.SEL, Id, c_ulong, Id) callconv(.c) Id;
517 const parse_fn: ParseFn = @ptrCast(&objc.objc_msgSend);
518 return parse_fn(NSJSONSerialization, sel_parse, data, 0, null) orelse {
519 std.log.warn("a shortcut's userInfo did not survive NSJSONSerialization; refusing the batch", .{});
520 return bridge_error.BridgeError.InvalidParameter;
521 };
522}
523
524// =============================================================================
525// Tests. Host-only by construction: everything that decides what the page
526// sees — routing, validation, reply shaping, the userInfo byte walk — is a
527// pure function beside the two UIKit writes.
528// =============================================================================
529
530const testing = std.testing;
531
532test "every declared action is one the dispatcher routes" {
533 for (capability_actions) |decl| {
534 if (routeFor(decl.name) == null) {
535 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
536 return error.DeclaredActionDoesNotRoute;
537 }
538 }
539}
540
541test "every route the dispatcher has is a declared action" {
542 // Each declaration must claim a distinct route and every route must be
543 // claimed — counting alone would let two rows share one route while
544 // another went undeclared.
545 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
546 for (capability_actions) |decl| {
547 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
548 const slot = @backingInt(route);
549 if (claimed[slot]) return error.TwoDeclarationsShareARoute;
550 claimed[slot] = true;
551 }
552 for (claimed) |taken| {
553 if (!taken) return error.RouteNotDeclared;
554 }
555}
556
557test "both actions reply with a result the page awaits" {
558 for (capability_actions) |decl| {
559 try testing.expectEqual(capabilities.Reply.result, decl.reply);
560 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
561 }
562}
563
564test "the action names are the ones the Swift dispatcher answers" {
565 // The wire contract, spelled out: the conformance scan compares `A`
566 // against `CraftApp.swift`'s case labels and catches one drifting, but not
567 // both renamed in step.
568 try testing.expectEqualStrings("setShortcuts", A.set_shortcuts);
569 try testing.expectEqualStrings("clearShortcuts", A.clear_shortcuts);
570}
571
572test "an action the namespace does not serve is reported, not ignored" {
573 var bridge = ShortcutsBridge.init(testing.allocator);
574 defer bridge.deinit();
575
576 try testing.expectError(
577 bridge_error.BridgeError.UnknownAction,
578 bridge.handleMessage("noSuchAction", "{}"),
579 );
580 // The Swift *helper* names, which are not action names — accepting one
581 // would serve an action the spec's dispatcher does not have.
582 try testing.expectError(
583 bridge_error.BridgeError.UnknownAction,
584 bridge.handleMessage("setAppShortcuts", "{\"shortcuts\":[]}"),
585 );
586 try testing.expectError(
587 bridge_error.BridgeError.UnknownAction,
588 bridge.handleMessage("clearAppShortcuts", "{}"),
589 );
590 // Casing is how a real typo arrives.
591 try testing.expectError(
592 bridge_error.BridgeError.UnknownAction,
593 bridge.handleMessage("setshortcuts", "{}"),
594 );
595}
596
597test "a malformed payload is reported as bad JSON, not as a missing field" {
598 var bridge = ShortcutsBridge.init(testing.allocator);
599 defer bridge.deinit();
600
601 try testing.expectError(
602 bridge_error.BridgeError.InvalidJSON,
603 bridge.handleMessage(A.set_shortcuts, "{not json"),
604 );
605}
606
607test "a missing shortcuts field is an answer on every platform, not a hang" {
608 // The headline divergence: Swift's `if let ... as? [[String: Any]]` has no
609 // `else`, so this exact payload leaves the page's promise unsettled
610 // forever. Validation also runs before any platform gate, so the page gets
611 // the specific error rather than UnsupportedPlatform wherever this runs.
612 var bridge = ShortcutsBridge.init(testing.allocator);
613 defer bridge.deinit();
614
615 try testing.expectError(
616 bridge_error.BridgeError.MissingData,
617 bridge.handleMessage(A.set_shortcuts, "{}"),
618 );
619 try testing.expectError(
620 bridge_error.BridgeError.InvalidParameter,
621 bridge.handleMessage(A.set_shortcuts, "{\"shortcuts\":null}"),
622 );
623 try testing.expectError(
624 bridge_error.BridgeError.InvalidParameter,
625 bridge.handleMessage(A.set_shortcuts, "{\"shortcuts\":\"new-message\"}"),
626 );
627}
628
629fn expectParseError(json: []const u8, expected: anyerror) !void {
630 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
631 defer parsed.deinit();
632 try testing.expectError(expected, parseShortcuts(testing.allocator, parsed.value));
633}
634
635test "the fields the injected JS sends are the fields that are read" {
636 // test-bridges.html's exact batch, plus a userInfo the contract permits.
637 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator,
638 \\{"shortcuts":[
639 \\ {"type":"new-message","title":"New Message","subtitle":"Start composing","iconName":"square.and.pencil"},
640 \\ {"type":"search","title":"Search","iconName":"magnifyingglass"},
641 \\ {"type":"settings","title":"Settings","iconName":"gear","userInfo":{"tab":"general"}}
642 \\]}
643 , .{});
644 defer parsed.deinit();
645
646 const shortcuts = try parseShortcuts(testing.allocator, parsed.value);
647 defer testing.allocator.free(shortcuts);
648
649 try testing.expectEqual(@as(usize, 3), shortcuts.len);
650 try testing.expectEqualStrings("new-message", shortcuts[0].type);
651 try testing.expectEqualStrings("New Message", shortcuts[0].title);
652 try testing.expectEqualStrings("Start composing", shortcuts[0].subtitle.?);
653 try testing.expectEqualStrings("square.and.pencil", shortcuts[0].icon_name.?);
654 try testing.expect(shortcuts[0].user_info == null);
655 // The second element sends no subtitle, which must be "none", not "".
656 try testing.expect(shortcuts[1].subtitle == null);
657 try testing.expectEqualStrings("magnifyingglass", shortcuts[1].icon_name.?);
658 // userInfo rides along whole rather than being dropped.
659 const info = shortcuts[2].user_info orelse return error.UserInfoDropped;
660 try testing.expectEqualStrings("general", info.object.get("tab").?.string);
661}
662
663test "an empty batch is a valid request, not an error and not a clear" {
664 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"shortcuts\":[]}", .{});
665 defer parsed.deinit();
666
667 const shortcuts = try parseShortcuts(testing.allocator, parsed.value);
668 defer testing.allocator.free(shortcuts);
669 try testing.expectEqual(@as(usize, 0), shortcuts.len);
670}
671
672test "a malformed element refuses the batch instead of being skipped" {
673 // Swift `continue`s these and reports a smaller count — an element
674 // silently dropped under a success. The whole batch is refused so `count`
675 // can never quietly disagree with what the page sent.
676 try expectParseError(
677 "{\"shortcuts\":[{\"title\":\"No Type\"}]}",
678 bridge_error.BridgeError.InvalidParameter,
679 );
680 try expectParseError(
681 "{\"shortcuts\":[{\"type\":\"no-title\"}]}",
682 bridge_error.BridgeError.InvalidParameter,
683 );
684 // A good element does not buy a bad one a pass.
685 try expectParseError(
686 "{\"shortcuts\":[{\"type\":\"ok\",\"title\":\"Ok\"},42]}",
687 bridge_error.BridgeError.InvalidParameter,
688 );
689}
690
691test "a required field of the wrong type is refused, not coerced" {
692 try expectParseError(
693 "{\"shortcuts\":[{\"type\":7,\"title\":\"T\"}]}",
694 bridge_error.BridgeError.InvalidParameter,
695 );
696 try expectParseError(
697 "{\"shortcuts\":[{\"type\":\"t\",\"title\":null}]}",
698 bridge_error.BridgeError.InvalidParameter,
699 );
700}
701
702test "null and absent optionals both mean none; wrong types are refused" {
703 // Swift's `as? String` folds NSNull and a number to nil alike — the first
704 // is the page saying "none", the second is a field silently dropped while
705 // the shortcut installs anyway.
706 var parsed = try std.json.parseFromSlice(testing_value_type, testing.allocator, "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"subtitle\":null,\"iconName\":null,\"userInfo\":null}]}", .{});
707 defer parsed.deinit();
708
709 const shortcuts = try parseShortcuts(testing.allocator, parsed.value);
710 defer testing.allocator.free(shortcuts);
711 try testing.expect(shortcuts[0].subtitle == null);
712 try testing.expect(shortcuts[0].icon_name == null);
713 try testing.expect(shortcuts[0].user_info == null);
714
715 try expectParseError(
716 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"subtitle\":3}]}",
717 bridge_error.BridgeError.InvalidParameter,
718 );
719 try expectParseError(
720 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"iconName\":[]}]}",
721 bridge_error.BridgeError.InvalidParameter,
722 );
723 try expectParseError(
724 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"userInfo\":\"not an object\"}]}",
725 bridge_error.BridgeError.InvalidParameter,
726 );
727}
728
729// Spelled once: `std.json.Value` as a type argument inside a string-heavy test.
730const testing_value_type = std.json.Value;
731
732test "a NUL in any UIKit-bound string is refused, not truncated" {
733 // All four reach UIKit via `stringWithUTF8String:`, which stops at the
734 // first NUL; `type` is the identity a future activation handler would
735 // match on, so a truncated one is a different shortcut reported installed.
736 try expectParseError(
737 "{\"shortcuts\":[{\"type\":\"a\\u0000b\",\"title\":\"T\"}]}",
738 bridge_error.BridgeError.InvalidParameter,
739 );
740 try expectParseError(
741 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"a\\u0000b\"}]}",
742 bridge_error.BridgeError.InvalidParameter,
743 );
744 try expectParseError(
745 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"subtitle\":\"a\\u0000b\"}]}",
746 bridge_error.BridgeError.InvalidParameter,
747 );
748 try expectParseError(
749 "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"iconName\":\"a\\u0000b\"}]}",
750 bridge_error.BridgeError.InvalidParameter,
751 );
752
753 // A NUL inside userInfo is *kept*: that path never crosses a C string, so
754 // refusing it would drop a value the handler can carry perfectly well.
755 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\",\"userInfo\":{\"k\":\"a\\u0000b\"}}]}", .{});
756 defer parsed.deinit();
757 const shortcuts = try parseShortcuts(testing.allocator, parsed.value);
758 defer testing.allocator.free(shortcuts);
759 const info = shortcuts[0].user_info orelse return error.UserInfoDropped;
760 try testing.expectEqualSlices(u8, &[_]u8{ 'a', 0, 'b' }, info.object.get("k").?.string);
761}
762
763test "a payload that is not an object is bad JSON, not a missing field" {
764 try expectParseError("[]", bridge_error.BridgeError.InvalidJSON);
765 try expectParseError("\"shortcuts\"", bridge_error.BridgeError.InvalidJSON);
766}
767
768test "the count reply is the Swift dictionary verbatim, and is JSON" {
769 var buf: [40]u8 = undefined;
770 try testing.expectEqualStrings("{\"count\":0}", try countReply(&buf, 0));
771 try testing.expectEqualStrings("{\"count\":3}", try countReply(&buf, 3));
772
773 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, try countReply(&buf, 3), .{});
774 defer parsed.deinit();
775 try testing.expectEqual(@as(i64, 3), parsed.value.object.get("count").?.integer);
776}
777
778test "the clear reply is the bare fragment true, not an object" {
779 // Swift resolves with `.fragmentsAllowed`, and the page renders
780 // 'Shortcuts cleared: ' + result — wrapping this in {"success":true} would
781 // "fix" it into displaying [object Object].
782 try testing.expectEqualStrings("true", clear_reply);
783
784 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, clear_reply, .{});
785 defer parsed.deinit();
786 try testing.expect(parsed.value == .bool);
787 try testing.expect(parsed.value.bool);
788}
789
790test "the userInfo byte walk survives a std.json round trip" {
791 const source =
792 \\{"s":"quo\"te\\slash","n":-7,"f":1.5,"b":true,"z":null,"a":[1,"two",{"deep":false}]}
793 ;
794 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, source, .{});
795 defer parsed.deinit();
796
797 var out: std.ArrayListUnmanaged(u8) = .empty;
798 defer out.deinit(testing.allocator);
799 try serializeJsonValue(testing.allocator, &out, parsed.value);
800
801 var round = try std.json.parseFromSlice(std.json.Value, testing.allocator, out.items, .{});
802 defer round.deinit();
803 const obj = round.value.object;
804 try testing.expectEqualStrings("quo\"te\\slash", obj.get("s").?.string);
805 try testing.expectEqual(@as(i64, -7), obj.get("n").?.integer);
806 try testing.expectEqual(@as(f64, 1.5), obj.get("f").?.float);
807 try testing.expect(obj.get("b").?.bool);
808 try testing.expect(obj.get("z").? == .null);
809 try testing.expectEqualStrings("two", obj.get("a").?.array.items[1].string);
810 try testing.expect(!obj.get("a").?.array.items[2].object.get("deep").?.bool);
811}
812
813test "the byte walk escapes control bytes Foundation would refuse raw" {
814 // The exact miss in bridge_handoff.zig's older walk: a NUL emitted as the
815 // raw byte is invalid JSON, so NSJSONSerialization would answer nil and a
816 // legal userInfo would be refused. \u0000 in, \u0000 out.
817 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"k\":\"a\\u0000b\"}", .{});
818 defer parsed.deinit();
819
820 var out: std.ArrayListUnmanaged(u8) = .empty;
821 defer out.deinit(testing.allocator);
822 try serializeJsonValue(testing.allocator, &out, parsed.value);
823
824 try testing.expect(std.mem.indexOf(u8, out.items, "\\u0000") != null);
825 try testing.expect(std.mem.indexOfScalar(u8, out.items, 0) == null);
826}
827
828test "a float of extreme magnitude is rendered whole, not refused" {
829 // 1e300 is 301 decimal digits under `{d}` and 5e-324 — the smallest
830 // subnormal — is 326; both are legal JSON that std.json parses to
831 // `.float`. The 64-byte buffer this pins against answered them
832 // NoSpaceLeft, which reached the page as NativeCallFailed for a lawful
833 // userInfo.
834 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"big\":1e300,\"tiny\":5e-324}", .{});
835 defer parsed.deinit();
836
837 var out: std.ArrayListUnmanaged(u8) = .empty;
838 defer out.deinit(testing.allocator);
839 try serializeJsonValue(testing.allocator, &out, parsed.value);
840
841 // Still JSON after the digits stretch. The 301-digit rendering of 1e300
842 // is integer-shaped and too wide for i64, so std.json re-reads it as
843 // `.number_string` — its spelling for a number wider than its scalars;
844 // Foundation's parser has no such split and reads it as one number.
845 var round = try std.json.parseFromSlice(std.json.Value, testing.allocator, out.items, .{});
846 defer round.deinit();
847 const big = round.value.object.get("big").?.number_string;
848 try testing.expectEqual(@as(usize, 301), big.len);
849 try testing.expectEqual(@as(u8, '1'), big[0]);
850 // The subnormal renders as 0.00…05, floats on the re-parse, and Ryu's
851 // shortest digits make the value exact.
852 try testing.expectEqual(@as(f64, 5e-324), round.value.object.get("tiny").?.float);
853}
854
855test "a non-finite float is refused before Foundation sees it" {
856 // std.json parses 1e999 to inf; `{d}` would render it as `inf`, which is
857 // not JSON, and the failure would surface as an opaque nil three calls
858 // later. Refusing here keeps the diagnosis one frame deep.
859 var out: std.ArrayListUnmanaged(u8) = .empty;
860 defer out.deinit(testing.allocator);
861 try testing.expectError(
862 bridge_error.BridgeError.InvalidParameter,
863 serializeJsonValue(testing.allocator, &out, .{ .float = std.math.inf(f64) }),
864 );
865}
866
867// The remaining tests exercise the Objective-C half against the live runtime —
868// libobjc and Foundation on a macOS host, no UIKit and no device required.
869
870test "userInfo becomes a Foundation dictionary with its NUL intact" {
871 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
872
873 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"route\":\"a\\u0000b\",\"depth\":3}", .{});
874 defer parsed.deinit();
875
876 const dict = try toFoundationObject(testing.allocator, parsed.value);
877 try testing.expect(try isKindOf(dict, "NSDictionary"));
878
879 // The value under "route" must be an NSString of *three* characters —
880 // proving the JSON-bytes route carries what `stringWithUTF8String:`
881 // truncates, which is the whole reason userInfo takes it.
882 const route = try dictValueForKey(testing.allocator, dict, "route");
883 try testing.expect(route != null);
884 try testing.expect(try isKindOf(route, "NSString"));
885 try testing.expectEqual(@as(usize, 3), try nsStringLength(route));
886}
887
888test "a missing UIApplication is reported rather than read past" {
889 // macOS links no UIKit, which is the same shape as an app extension or a
890 // pre-UIApplicationMain process on a device: `objc_getClass` answers null
891 // and the handler must error, not install into nothing or fabricate
892 // `true`. Pinned to macOS — on iOS the class exists and the right answer
893 // is a real install.
894 if (builtin.target.os.tag != .macos) return error.SkipZigTest;
895
896 var bridge = ShortcutsBridge.init(testing.allocator);
897 defer bridge.deinit();
898
899 try testing.expectError(error.ClassNotFound, bridge.handleMessage(A.clear_shortcuts, "{}"));
900 try testing.expectError(
901 error.ClassNotFound,
902 bridge.handleMessage(A.set_shortcuts, "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\"}]}"),
903 );
904}
905
906test "off Darwin the platform gate answers instead of crashing" {
907 if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
908
909 var bridge = ShortcutsBridge.init(testing.allocator);
910 defer bridge.deinit();
911
912 try testing.expectError(
913 error.UnsupportedPlatform,
914 bridge.handleMessage(A.set_shortcuts, "{\"shortcuts\":[{\"type\":\"t\",\"title\":\"T\"}]}"),
915 );
916 try testing.expectError(error.UnsupportedPlatform, bridge.handleMessage(A.clear_shortcuts, "{}"));
917}
918
919/// `-[NSObject isKindOfClass:]` for the host tests above.
920fn isKindOf(obj: Id, class_name: [*:0]const u8) !bool {
921 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
922
923 const cls = objc.objc_getClass(class_name) orelse return error.ClassNotFound;
924 const sel_kind = objc.sel_registerName("isKindOfClass:") orelse return error.SelectorNotFound;
925 const Fn = *const fn (Id, objc.SEL, Id) callconv(.c) bool;
926 const func: Fn = @ptrCast(&objc.objc_msgSend);
927 return func(obj, sel_kind, cls);
928}
929
930/// `-[NSDictionary objectForKey:]` for the host tests above.
931fn dictValueForKey(allocator: std.mem.Allocator, dict: Id, key: []const u8) !Id {
932 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
933
934 const ns_key = try objc.createNSString(key, allocator);
935 if (ns_key == null) return error.NativeCallFailed;
936 const sel_obj = objc.sel_registerName("objectForKey:") orelse return error.SelectorNotFound;
937 return objc.msgSendId1(dict, sel_obj, ns_key);
938}
939
940/// `-[NSString length]` — UTF-16 code units, which is what makes it the right
941/// probe for "did the NUL survive": "a\x00b" is three either way.
942fn nsStringLength(ns: Id) !usize {
943 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
944
945 const sel_length = objc.sel_registerName("length") orelse return error.SelectorNotFound;
946 const Fn = *const fn (Id, objc.SEL) callconv(.c) c_ulong;
947 const func: Fn = @ptrCast(&objc.objc_msgSend);
948 return @intCast(func(ns, sel_length));
949}
packages/zig/src/ios_async.zigadded+295-0
Changes to packages/zig/src/ios_async.zig
@@ -0,0 +1,295 @@
1//! The machinery an asynchronous reply needs, built once.
2//!
3//! Everything served so far answers from inside the dispatch: the request id
4//! is on `request_context`'s stack, the reply reads it, done. A completion
5//! handler breaks both halves of that at once — it fires after the dispatch
6//! frame is gone (so the id is no longer anywhere), and it fires on whatever
7//! queue the framework felt like (so `evaluateJavaScript`, which is
8//! main-thread-only, cannot be called from it directly).
9//!
10//! Getting this wrong is not hypothetical; it is the repo's inheritance.
11//! `request_context.current()` returning null in a late reply silently drops
12//! correlation to action-name matching, which hands caller B's answer to
13//! caller A whenever two calls of one action are in flight. And the previous
14//! block implementation was built on the stack and handed to an async API,
15//! with its own doc comment asserting a lifetime ("valid for duration of
16//! call") that async precisely does not honour.
17//!
18//! Three pieces, each chosen for being checkable:
19//!
20//! - a slot pool that captures {request id, action} at dispatch time, with
21//! generation counters so a stale or double-firing completion cannot
22//! resolve a slot that has been reused;
23//! - completion blocks that are module-level and flagged BLOCK_IS_GLOBAL,
24//! because `Block_copy` on a global block returns the same pointer —
25//! no heap copy, no descriptor lifetime, no copy/dispose helpers. Each
26//! pool slot has its own pre-built block with the slot index baked in,
27//! which is how a non-capturing block knows who it is;
28//! - delivery hopped to the main queue with `dispatch_async_f` — the
29//! function-pointer variant, so the hop itself needs no block at all.
30
31const std = ;
32const builtin = ;
33const objc_runtime = ;
34const request_context = ;
35const bridge_error = ;
36const compat_mutex = ;
37
38const objc = objc_runtime.objc;
39const is_darwin = builtin.target.os.tag.isDarwin();
40
41/// How many async calls may be in flight at once. A page that exceeds this
42/// gets an explicit Busy error, which is an answer — the alternative was a
43/// silently dropped reply.
44pub const max_in_flight = 16;
45
46const Slot = struct {
47 /// Incremented on every acquire and release. A completion carrying a
48 /// stale generation is a late or duplicate fire for a call that has
49 /// already been answered; it must do nothing rather than resolve
50 /// whoever owns the slot now.
51 generation: u32 = 0,
52 in_use: bool = false,
53 /// The id the reply names. -1 for a call the page sent without one.
54 request_id: i64 = -1,
55 /// The action, copied into fixed storage: the dispatch frame's slices are
56 /// dead by the time the completion fires.
57 action_buf: [64]u8 = undefined,
58 action_len: usize = 0,
59 /// What the completion resolved. Written on the firing thread, read on
60 /// the main thread after the dispatch_async_f hop.
61 granted: bool = false,
62};
63
64var slots: [max_in_flight]Slot = @splat(.{});
65var slots_mutex: compat_mutex.Mutex = .{};
66
67/// A slot ticket: which slot, and which lease of it.
68pub const Ticket = struct {
69 index: u5,
70 generation: u32,
71};
72
73/// Capture the current request's identity for a reply that will arrive after
74/// the dispatch is gone. Called from inside the dispatch, which is the only
75/// place `request_context.current()` still means this call.
76///
77/// Null means the pool is full. The caller must answer Busy — returning
78/// silence here would reproduce the exact bug this file exists to prevent.
79pub fn acquire(action: []const u8) ?Ticket {
80 if (action.len > 64) return null;
81
82 slots_mutex.lock();
83 defer slots_mutex.unlock();
84
85 for (&slots, 0..) |*slot, i| {
86 if (slot.in_use) continue;
87 slot.in_use = true;
88 slot.generation +%= 1;
89 slot.request_id = if (request_context.current()) |id| @intCast(id) else -1;
90 @memcpy(slot.action_buf[0..action.len], action);
91 slot.action_len = action.len;
92 return .{ .index = @intCast(i), .generation = slot.generation };
93 }
94 return null;
95}
96
97/// Release without replying — for the error path between acquire and the
98/// framework call, where the caller is about to answer synchronously itself.
99pub fn abandon(ticket: Ticket) void {
100 slots_mutex.lock();
101 defer slots_mutex.unlock();
102 const slot = &slots[ticket.index];
103 if (slot.generation != ticket.generation or !slot.in_use) return;
104 slot.in_use = false;
105 slot.generation +%= 1;
106}
107
108// ---------------------------------------------------------------------------
109// The block pool: one pre-built BOOL-completion block per slot.
110// ---------------------------------------------------------------------------
111
112const BlockDescriptor = extern struct {
113 reserved: c_ulong = 0,
114 size: c_ulong,
115};
116
117/// void (^)(BOOL) — the shape of `requestAccessForMediaType:completionHandler:`
118/// and, with the error parameter ignored by the invoke, close enough to
119/// `requestAuthorizationWithOptions:` that each gets its own invoke below.
120const BoolBlock = extern struct {
121 isa: ?*anyopaque,
122 flags: c_int,
123 reserved: c_int = 0,
124 invoke: *const anyopaque,
125 descriptor: *const BlockDescriptor,
126};
127
128/// 1 << 28. A global block is never copied: `Block_copy` returns the same
129/// pointer. That is the entire trick — no heap block, no copy/dispose pair,
130/// no descriptor lifetime, and the pool's blocks are safely handed to any
131/// API that escapes them.
132const BLOCK_IS_GLOBAL: c_int = 1 << 28;
133
134const bool_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolBlock) };
135
136extern var _NSConcreteGlobalBlock: anyopaque;
137
138/// The per-slot invokes. Comptime-generated so each block knows its slot
139/// without capturing anything: the index is baked into the function, and the
140/// function is baked into the block.
141fn makeBoolInvoke(comptime index: u5) *const anyopaque {
142 const S = struct {
143 fn invoke(_: *const BoolBlock, granted: bool) callconv(.c) void {
144 completionFired(index, granted);
145 }
146 };
147 return @ptrCast(&S.invoke);
148}
149
150/// void (^)(BOOL, NSError *) — `requestAuthorizationWithOptions:` and friends.
151/// The error object is deliberately unused: the granted flag is the answer
152/// the page's contract carries, and Swift discarded the error here too.
153fn makeBoolErrorInvoke(comptime index: u5) *const anyopaque {
154 const S = struct {
155 fn invoke(_: *const BoolBlock, granted: bool, _: objc.id) callconv(.c) void {
156 completionFired(index, granted);
157 }
158 };
159 return @ptrCast(&S.invoke);
160}
161
162fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [max_in_flight]BoolBlock {
163 var out: [max_in_flight]BoolBlock = undefined;
164 for (&out, 0..) |*b, i| {
165 b.* = .{
166 .isa = &_NSConcreteGlobalBlock,
167 .flags = BLOCK_IS_GLOBAL,
168 .invoke = maker(@intCast(i)),
169 .descriptor = &bool_block_descriptor,
170 };
171 }
172 return out;
173}
174
175var bool_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolInvoke) else undefined;
176var bool_error_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolErrorInvoke) else undefined;
177
178/// The block to pass as a `void (^)(BOOL)` completion handler for the call
179/// this ticket was acquired for.
180pub fn boolBlock(ticket: Ticket) *anyopaque {
181 return @ptrCast(&bool_blocks[ticket.index]);
182}
183
184/// The block to pass as a `void (^)(BOOL, NSError *)` completion handler.
185pub fn boolErrorBlock(ticket: Ticket) *anyopaque {
186 return @ptrCast(&bool_error_blocks[ticket.index]);
187}
188
189// ---------------------------------------------------------------------------
190// Firing: any thread → main queue → reply.
191// ---------------------------------------------------------------------------
192
193const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
194extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
195/// `dispatch_get_main_queue()` is a macro over this global.
196extern var _dispatch_main_q: anyopaque;
197
198fn completionFired(index: u5, granted: bool) void {
199 // Record the outcome under the lock, but deliver from the main queue:
200 // the reply ends in `evaluateJavaScript`, which is main-thread-only, and
201 // this may be running on whatever queue the framework chose.
202 {
203 slots_mutex.lock();
204 defer slots_mutex.unlock();
205 const slot = &slots[index];
206 if (!slot.in_use) return; // stale fire for an abandoned slot
207 slot.granted = granted;
208 }
209
210 // The context pointer carries only the slot index; everything else stays
211 // in the slot, guarded by its generation.
212 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), deliverOnMain);
213}
214
215fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
216 const index: usize = @intFromPtr(context orelse return) - 1;
217
218 var action_buf: [64]u8 = undefined;
219 var action_len: usize = 0;
220 var request_id: i64 = -1;
221 var granted = false;
222 {
223 slots_mutex.lock();
224 defer slots_mutex.unlock();
225 const slot = &slots[index];
226 if (!slot.in_use) return;
227 action_len = slot.action_len;
228 @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]);
229 request_id = slot.request_id;
230 granted = slot.granted;
231 slot.in_use = false;
232 slot.generation +%= 1;
233 }
234
235 const action = action_buf[0..action_len];
236
237 // Restore the identity captured at dispatch, so the reply names the call
238 // that is actually waiting — the whole point of this file.
239 request_context.push(if (request_id < 0) null else @intCast(request_id));
240 defer request_context.pop();
241
242 bridge_error.sendResultToJS(
243 std.heap.c_allocator,
244 action,
245 if (granted) "\"granted\"" else "\"denied\"",
246 );
247}
248
249const testing = std.testing;
250
251test "acquire captures the current request id, release makes the slot reusable" {
252 request_context.push(42);
253 defer request_context.pop();
254
255 const ticket = acquire("requestPermission") orelse return error.PoolUnexpectedlyFull;
256 try testing.expectEqual(@as(i64, 42), slots[ticket.index].request_id);
257 try testing.expectEqualStrings("requestPermission", slots[ticket.index].action_buf[0..slots[ticket.index].action_len]);
258
259 abandon(ticket);
260 try testing.expect(!slots[ticket.index].in_use);
261}
262
263test "a stale ticket cannot abandon a slot that was re-leased" {
264 const first = acquire("a") orelse return error.PoolUnexpectedlyFull;
265 abandon(first);
266
267 // Re-lease the same slot; the old ticket's generation is now stale.
268 const second = acquire("b") orelse return error.PoolUnexpectedlyFull;
269 defer abandon(second);
270
271 abandon(first); // must be a no-op
272 try testing.expect(slots[second.index].in_use);
273}
274
275test "the pool exhausts loudly rather than silently" {
276 var tickets: [max_in_flight]Ticket = undefined;
277 var n: usize = 0;
278 defer for (tickets[0..n]) |t| abandon(t);
279
280 while (n < max_in_flight) : (n += 1) {
281 tickets[n] = acquire("x") orelse break;
282 }
283 // Whatever other tests leased, the pool must end by refusing with null —
284 // never by handing out a slot it does not have.
285 try testing.expect(acquire("one-too-many") == null);
286}
287
288test "an action longer than the slot's storage is refused at acquire" {
289 // Truncating it instead would make the eventual reply name a different
290 // action than the page called — correlation by wrong name, reported as
291 // success.
292 // (`**` no longer lexes as one operator in this Zig; @splat says the same.)
293 const long: [65]u8 = @splat('a');
294 try testing.expect(acquire(&long) == null);
295}
packages/zig/src/ios_dispatch.zigmodified+15-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -3,6 +3,7 @@ const builtin = ;
33const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
6pub const ios_async = ;
67const bridge_mobile = ;
78const bridge_mobile_clipboard = ;
89const bridge_mobile_haptics = ;
@@ -10,6 +11,11 @@ const bridge_mobile_device = ;
1011const bridge_mobile_system = ;
1112const bridge_mobile_display = ;
1213const bridge_mobile_storage = ;
14const bridge_mobile_misc = ;
15const bridge_mobile_shortcuts = ;
16const bridge_mobile_securestore = ;
17const bridge_mobile_biometric = ;
18const bridge_mobile_permissions = ;
1319
1420const objc = objc_runtime.objc;
1521
@@ -202,6 +208,11 @@ const mobile_bridges = .{
202208 bridge_mobile_system.SystemBridge,
203209 bridge_mobile_display.DisplayBridge,
204210 bridge_mobile_storage.StorageBridge,
211 bridge_mobile_misc.MiscBridge,
212 bridge_mobile_shortcuts.ShortcutsBridge,
213 bridge_mobile_securestore.SecureStoreBridge,
214 bridge_mobile_biometric.BiometricStoreBridge,
215 bridge_mobile_permissions.PermissionsBridge,
205216};
206217
207218/// Narrow an arbitrary handler error to one the page's error codes can express.
@@ -355,6 +366,10 @@ fn buildShimError(
355366 try out.append(allocator, '}');
356367}
357368
369test {
370 _ = ios_async;
371}
372
358373const testing = std.testing;
359374
360375test "an envelope without a type is rejected" {
packages/zig/test/ios_conformance_test.zigmodified+9-2
Changes to packages/zig/test/ios_conformance_test.zig
@@ -31,6 +31,11 @@ const zig_sources = [_][]const u8{
3131 @embedFile("src/bridge_mobile_system.zig"),
3232 @embedFile("src/bridge_mobile_display.zig"),
3333 @embedFile("src/bridge_mobile_storage.zig"),
34 @embedFile("src/bridge_mobile_misc.zig"),
35 @embedFile("src/bridge_mobile_shortcuts.zig"),
36 @embedFile("src/bridge_mobile_securestore.zig"),
37 @embedFile("src/bridge_mobile_biometric.zig"),
38 @embedFile("src/bridge_mobile_permissions.zig"),
3439};
3540
3641/// The action list lives in the `switch action` block, and nowhere else.
@@ -54,8 +59,10 @@ const dispatch_end = "func webView(";
5459/// exists to force.
5560///
5661/// History: 105 after the vertical slice (getDeviceInfo); 86 after Tier 0
57/// landed clipboard, haptics, device, system, display, and storage.
58const max_not_yet_migrated: usize = 86;
62/// landed clipboard, haptics, device, system, display, and storage; 74 after
63/// the secure tier landed flashlight, shortcuts, the Keychain secure store,
64/// biometric persistence, and permissions — including the first async reply.
65const max_not_yet_migrated: usize = 74;
5966
6067fn dispatcherRegion() []const u8 {
6168 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";