also looking at this
iOS Phase 4: the secure tier — async replies, Keychain, permissions; ratchet 86 → 74
#86
11 files
+4297
-4
| @@ -33,6 +33,24 @@ clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \ | ||
| 33 | 33 | clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \ |
| 34 | 34 | "$FIXTURE/page.m" -o "$OUT/page.o" |
| 35 | 35 | |
| 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). | |
| 42 | cat > "$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> | |
| 52 | ENTS | |
| 53 | ||
| 36 | 54 | echo "==> linking CraftSlice" |
| 37 | 55 | # The shim class has to survive into the binary for |
| 38 | 56 | # `objc_getClass("CraftSwiftShim")` to find it at runtime. Compiling it into |
| @@ -45,10 +63,16 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \ | ||
| 45 | 63 | "$OUT/main.o" "$OUT/page.o" \ |
| 46 | 64 | "$ROOT/packages/zig/zig-out/lib/$LIB" \ |
| 47 | 65 | -framework UIKit -framework WebKit -framework Foundation -framework Security \ |
| 66 | -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \ | |
| 48 | 67 | -o "$APP/CraftSlice" |
| 49 | 68 | |
| 50 | 69 | cp "$FIXTURE/Info.plist" "$APP/Info.plist" |
| 51 | 70 | |
| 71 | # Ad-hoc signature. The identity entitlements live in the __entitlements | |
| 72 | # section above; signing them in instead makes the launch be denied. | |
| 73 | codesign --force --sign - "$APP" | |
| 74 | ||
| 75 | ||
| 52 | 76 | echo "==> booting a simulator" |
| 53 | 77 | UDID="$(xcrun simctl list devices available -j \ |
| 54 | 78 | | 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=$! | ||
| 66 | 90 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 67 | 91 | # fixed amount, so a slow boot does not read as a failure. |
| 68 | 92 | for _ 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 | |
| 70 | 94 | sleep 1 |
| 71 | 95 | done |
| 72 | 96 | kill "$LAUNCH_PID" 2>/dev/null || true |
| @@ -126,4 +150,8 @@ C8="$(count 8)" | ||
| 126 | 150 | [ "$C8" -ge 1 ] || { echo "FAIL: the Tier-0 action's reply never carried a real app state"; exit 1; } |
| 127 | 151 | echo "ok: Tier-0 action served by Zig with a live UIKit value (i=8 seen ${C8}x)" |
| 128 | 152 | |
| 153 | C10="$(count 10)" | |
| 154 | [ "$C10" -ge 1 ] || { echo "FAIL: the Keychain round trip never completed byte-identical"; exit 1; } | |
| 155 | echo "ok: secureSet/secureGet round-tripped a secret through the Keychain (i=10 seen ${C10}x)" | |
| 156 | ||
| 129 | 157 | echo "PASS" |
| @@ -13,6 +13,9 @@ | ||
| 13 | 13 | // i=5 that reply came back, having been answered by the host shim |
| 14 | 14 | // i=8 the page called a Tier-0 action Zig newly serves (getAppState) and |
| 15 | 15 | // 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 | |
| 16 | 19 | // i=6 the page called an action the shim answers by *rejecting* |
| 17 | 20 | // i=7 the rejection arrived via __craftBridgeError with its id, code, and |
| 18 | 21 | // an unmangled message — proving the error route and Zig-side escaping |
| @@ -27,7 +30,7 @@ const char craft_slice_page[] = | ||
| 27 | 30 | "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
| 28 | 31 | "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" |
| 29 | 32 | "</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" | |
| 31 | 34 | "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n" |
| 32 | 35 | "\n" |
| 33 | 36 | "// Read before anything else touches it: the user script is installed at\n" |
| @@ -50,6 +53,7 @@ const char craft_slice_page[] = | ||
| 50 | 53 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:7});\n" |
| 51 | 54 | " }\n" |
| 52 | 55 | "};\n" |
| 56 | "var SECRET = 'sl\\\\ice \"v\" 1';\n" | |
| 53 | 57 | "window.__craftBridgeResult = function (action, payload, id) {\n" |
| 54 | 58 | " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n" |
| 55 | 59 | " document.getElementById('s').textContent = window.__craftSliceAck;\n" |
| @@ -66,6 +70,13 @@ const char craft_slice_page[] = | ||
| 66 | 70 | " // protocol it uses for its own actions.\n" |
| 67 | 71 | " // A Tier-0 action served by Zig itself: the reply carries the UIKit\n" |
| 68 | 72 | " // 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" | |
| 69 | 80 | " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n" |
| 70 | 81 | " sentTierZero = true;\n" |
| 71 | 82 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n" |
| @@ -81,6 +92,10 @@ const char craft_slice_page[] = | ||
| 81 | 92 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n" |
| 82 | 93 | " // An action Zig does not serve, so it must reach the host shim.\n" |
| 83 | 94 | " 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" | |
| 84 | 99 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 85 | 100 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 86 | 101 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| @@ -409,6 +409,21 @@ pub fn build(b: *std.Build) void { | ||
| 409 | 409 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_storage.zig", .{ |
| 410 | 410 | .root_source_file = b.path("src/bridge_mobile_storage.zig"), |
| 411 | 411 | }); |
| 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 | }); | |
| 412 | 427 | |
| 413 | 428 | const menubar_tests = b.addTest(.{ |
| 414 | 429 | .root_module = b.createModule(.{ |
packages/zig/src/bridge_mobile_biometric.zigadded+658-0
| @@ -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 | ||
| 69 | const std = ; | |
| 70 | const capabilities = ; | |
| 71 | const bridge_error = ; | |
| 72 | const 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. | |
| 78 | pub 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. | |
| 88 | pub 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. | |
| 97 | const Route = enum { set_persistence, check_persistence, clear_persistence }; | |
| 98 | ||
| 99 | fn 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. | |
| 118 | var session_expiry_ms: ?f64 = null; | |
| 119 | ||
| 120 | pub 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. | |
| 194 | const 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. | |
| 204 | fn 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. | |
| 223 | fn 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`. | |
| 263 | fn 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. | |
| 277 | fn 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. | |
| 288 | const 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. | |
| 293 | const no_session_reply = "{\"isValid\":false,\"remainingSeconds\":0}"; | |
| 294 | ||
| 295 | /// The clear reply. Unconditional, as in Swift. | |
| 296 | const 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. | |
| 306 | fn 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 | ||
| 316 | const testing = std.testing; | |
| 317 | ||
| 318 | test "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 | ||
| 329 | test "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 | ||
| 356 | test "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 | ||
| 364 | test "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 | ||
| 373 | test "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 | ||
| 398 | test "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. | |
| 411 | fn 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 | ||
| 417 | test "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 | ||
| 423 | test "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 | ||
| 434 | test "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 | ||
| 452 | test "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 | ||
| 462 | test "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 | ||
| 472 | test "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 | ||
| 491 | test "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 | ||
| 502 | test "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 | ||
| 507 | test "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 | ||
| 516 | test "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 | ||
| 526 | test "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 | ||
| 534 | test "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 | ||
| 550 | test "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 | ||
| 565 | test "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 | ||
| 576 | test "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 | ||
| 598 | test "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 | ||
| 625 | test "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 | ||
| 647 | test "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 | } | |
| @@ -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 | ||
| 69 | const std = ; | |
| 70 | const builtin = ; | |
| 71 | const capabilities = ; | |
| 72 | const bridge_error = ; | |
| 73 | const objc_runtime = ; | |
| 74 | ||
| 75 | const objc = objc_runtime.objc; | |
| 76 | const 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. | |
| 85 | const 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. | |
| 90 | const AVCaptureTorchModeOff: c_long = 0; | |
| 91 | const 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. | |
| 99 | pub 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. | |
| 110 | pub 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. | |
| 121 | pub 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. | |
| 130 | const success_reply = "true"; | |
| 131 | ||
| 132 | pub 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. | |
| 172 | fn 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. | |
| 197 | fn 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. | |
| 213 | fn 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`. | |
| 225 | fn 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 | ||
| 295 | const testing = std.testing; | |
| 296 | ||
| 297 | test "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 | ||
| 304 | test "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 | ||
| 311 | test "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 | ||
| 327 | test "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 | ||
| 350 | test "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 | ||
| 372 | test "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. | |
| 385 | fn 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 | ||
| 391 | fn 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 | ||
| 397 | test "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 | ||
| 402 | test "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 | ||
| 411 | test "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 | ||
| 422 | test "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 | ||
| 427 | test "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 | ||
| 432 | test "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 | ||
| 441 | test "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
| @@ -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 | ||
| 99 | const std = ; | |
| 100 | const builtin = ; | |
| 101 | const capabilities = ; | |
| 102 | const bridge_error = ; | |
| 103 | const objc_runtime = ; | |
| 104 | const ios_async = ; | |
| 105 | ||
| 106 | const objc = objc_runtime.objc; | |
| 107 | const 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. | |
| 116 | const 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. | |
| 124 | pub 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. | |
| 137 | pub 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. | |
| 145 | const Route = enum { check, request }; | |
| 146 | ||
| 147 | fn 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`). | |
| 162 | const 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 | ||
| 177 | fn 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 | ||
| 196 | const granted_json = "\"granted\""; | |
| 197 | const denied_json = "\"denied\""; | |
| 198 | const restricted_json = "\"restricted\""; | |
| 199 | const 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. | |
| 207 | fn 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. | |
| 221 | fn 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. | |
| 235 | fn 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. | |
| 242 | fn 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. | |
| 250 | fn 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. | |
| 256 | fn 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 | ||
| 261 | const record_permission_undetermined: c_ulong = fourCC("undt"); | |
| 262 | const record_permission_denied: c_ulong = fourCC("deny"); | |
| 263 | const 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"`. | |
| 268 | fn 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. | |
| 280 | fn 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. | |
| 297 | fn 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 | ||
| 309 | pub 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. | |
| 466 | fn 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. | |
| 476 | const ek_entity_event: c_ulong = 0; | |
| 477 | const ek_entity_reminder: c_ulong = 1; | |
| 478 | ||
| 479 | /// `PHAccessLevel.readWrite` = 2 (NSInteger). `.addOnly` is 1; the spec | |
| 480 | /// checks readWrite. | |
| 481 | const ph_access_level_read_write: c_long = 2; | |
| 482 | ||
| 483 | /// `CNEntityType.contacts` = 0 (NSInteger) — the only entity type there is. | |
| 484 | const 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. | |
| 489 | const 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. | |
| 502 | fn 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`. | |
| 514 | fn 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. | |
| 526 | fn 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. | |
| 540 | fn 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. | |
| 553 | fn 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). | |
| 565 | fn 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]`. | |
| 576 | fn 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. | |
| 589 | fn 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. | |
| 601 | fn 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 | ||
| 613 | extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; | |
| 614 | ||
| 615 | /// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin. | |
| 616 | const 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. | |
| 630 | fn 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 | ||
| 654 | const testing = std.testing; | |
| 655 | ||
| 656 | test "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 | ||
| 667 | test "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 | ||
| 674 | test "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 | ||
| 683 | test "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 | ||
| 699 | test "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 | ||
| 717 | test "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 | ||
| 742 | test "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 | ||
| 757 | test "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 | ||
| 771 | fn 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 | ||
| 777 | fn 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 | ||
| 783 | test "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 | ||
| 793 | test "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 | ||
| 804 | test "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 | ||
| 818 | test "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 | ||
| 832 | test "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 | ||
| 844 | test "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 | ||
| 855 | test "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 | ||
| 872 | test "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 | ||
| 883 | test "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 | ||
| 894 | test "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 | ||
| 905 | test "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 | ||
| 919 | test "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 | ||
| 948 | test "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 | ||
| 959 | test "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 | ||
| 966 | test "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
| @@ -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 | ||
| 93 | const std = ; | |
| 94 | const builtin = ; | |
| 95 | const capabilities = ; | |
| 96 | const bridge_error = ; | |
| 97 | const objc_runtime = ; | |
| 98 | ||
| 99 | const 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. | |
| 108 | const 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`. | |
| 113 | const CFTypeRef = ?*anyopaque; | |
| 114 | ||
| 115 | /// `SInt32`, fixed by the ABI; every keychain status of interest is negative. | |
| 116 | const 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. | |
| 124 | extern "c" fn SecItemAdd(attributes: CFTypeRef, result: ?*CFTypeRef) OSStatus; | |
| 125 | extern "c" fn SecItemCopyMatching(query: CFTypeRef, result: ?*CFTypeRef) OSStatus; | |
| 126 | extern "c" fn SecItemDelete(query: CFTypeRef) OSStatus; | |
| 127 | extern "c" fn CFRelease(cf: CFTypeRef) void; | |
| 128 | ||
| 129 | extern "c" const kSecClass: CFTypeRef; | |
| 130 | extern "c" const kSecClassGenericPassword: CFTypeRef; | |
| 131 | extern "c" const kSecAttrAccount: CFTypeRef; | |
| 132 | extern "c" const kSecAttrAccessible: CFTypeRef; | |
| 133 | extern "c" const kSecAttrAccessibleWhenUnlockedThisDeviceOnly: CFTypeRef; | |
| 134 | extern "c" const kSecValueData: CFTypeRef; | |
| 135 | extern "c" const kSecReturnData: CFTypeRef; | |
| 136 | extern "c" const kSecMatchLimit: CFTypeRef; | |
| 137 | extern "c" const kSecMatchLimitOne: CFTypeRef; | |
| 138 | extern "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. | |
| 143 | const errSecSuccess: OSStatus = 0; | |
| 144 | const 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. | |
| 149 | pub 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`. | |
| 163 | pub 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. | |
| 176 | const Route = enum { set, get, remove, clear }; | |
| 177 | ||
| 178 | fn 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 | ||
| 186 | pub 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. | |
| 258 | fn 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. | |
| 269 | const 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. | |
| 276 | fn 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 | ||
| 290 | fn 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. | |
| 312 | fn 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. | |
| 319 | fn 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 `""`. | |
| 330 | fn 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. | |
| 348 | fn 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. | |
| 359 | fn 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. | |
| 384 | fn 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. | |
| 397 | fn 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. | |
| 417 | fn 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. | |
| 445 | fn 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. | |
| 501 | fn 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. | |
| 514 | fn 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. | |
| 532 | fn 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`. | |
| 543 | fn 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. | |
| 555 | fn 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 | ||
| 573 | const testing = std.testing; | |
| 574 | ||
| 575 | test "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 | ||
| 588 | test "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 | ||
| 598 | test "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 | ||
| 607 | test "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 | ||
| 633 | test "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 | ||
| 651 | test "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 | ||
| 682 | test "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 | ||
| 692 | fn 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 | ||
| 698 | fn 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 | ||
| 704 | test "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 | ||
| 723 | test "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 | ||
| 728 | test "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 | ||
| 736 | test "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 | ||
| 743 | test "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 | ||
| 755 | test "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 | ||
| 782 | test "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 | ||
| 787 | test "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 | ||
| 796 | test "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 | ||
| 813 | test "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 | ||
| 826 | test "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 | ||
| 839 | test "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 | ||
| 856 | test "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 | ||
| 864 | test "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 | ||
| 873 | test "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
| @@ -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 | ||
| 71 | const std = ; | |
| 72 | const builtin = ; | |
| 73 | const capabilities = ; | |
| 74 | const bridge_error = ; | |
| 75 | const objc_runtime = ; | |
| 76 | ||
| 77 | const 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. | |
| 86 | const 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. | |
| 90 | pub 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. | |
| 103 | pub 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`. | |
| 115 | const 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. | |
| 128 | const Route = enum { set_shortcuts, clear_shortcuts }; | |
| 129 | ||
| 130 | fn 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 | ||
| 136 | pub 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. | |
| 190 | const 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. | |
| 194 | fn 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. | |
| 209 | fn 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. | |
| 233 | fn 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. | |
| 262 | fn 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. | |
| 274 | fn 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. | |
| 291 | fn 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. | |
| 297 | fn 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. | |
| 312 | fn 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. | |
| 375 | fn 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. | |
| 386 | fn 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. | |
| 418 | fn 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. | |
| 432 | fn 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. | |
| 478 | fn 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. | |
| 501 | fn 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 | ||
| 530 | const testing = std.testing; | |
| 531 | ||
| 532 | test "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 | ||
| 541 | test "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 | ||
| 557 | test "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 | ||
| 564 | test "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 | ||
| 572 | test "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 | ||
| 597 | test "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 | ||
| 607 | test "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 | ||
| 629 | fn 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 | ||
| 635 | test "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 | ||
| 663 | test "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 | ||
| 672 | test "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 | ||
| 691 | test "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 | ||
| 702 | test "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. | |
| 730 | const testing_value_type = std.json.Value; | |
| 731 | ||
| 732 | test "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 | ||
| 763 | test "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 | ||
| 768 | test "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 | ||
| 778 | test "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 | ||
| 790 | test "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 | ||
| 813 | test "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 | ||
| 828 | test "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 | ||
| 855 | test "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 | ||
| 870 | test "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 | ||
| 888 | test "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 | ||
| 906 | test "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. | |
| 920 | fn 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. | |
| 931 | fn 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. | |
| 942 | fn 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 | } | |
| @@ -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 | ||
| 31 | const std = ; | |
| 32 | const builtin = ; | |
| 33 | const objc_runtime = ; | |
| 34 | const request_context = ; | |
| 35 | const bridge_error = ; | |
| 36 | const compat_mutex = ; | |
| 37 | ||
| 38 | const objc = objc_runtime.objc; | |
| 39 | const 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. | |
| 44 | pub const max_in_flight = 16; | |
| 45 | ||
| 46 | const 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 | ||
| 64 | var slots: [max_in_flight]Slot = @splat(.{}); | |
| 65 | var slots_mutex: compat_mutex.Mutex = .{}; | |
| 66 | ||
| 67 | /// A slot ticket: which slot, and which lease of it. | |
| 68 | pub 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. | |
| 79 | pub 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. | |
| 99 | pub 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 | ||
| 112 | const 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. | |
| 120 | const 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. | |
| 132 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 133 | ||
| 134 | const bool_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolBlock) }; | |
| 135 | ||
| 136 | extern 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. | |
| 141 | fn 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. | |
| 153 | fn 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 | ||
| 162 | fn 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 | ||
| 175 | var bool_blocks: [max_in_flight]BoolBlock = if (is_darwin) makeBlocks(makeBoolInvoke) else undefined; | |
| 176 | var 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. | |
| 180 | pub 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. | |
| 185 | pub 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 | ||
| 193 | const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; | |
| 194 | extern "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. | |
| 196 | extern var _dispatch_main_q: anyopaque; | |
| 197 | ||
| 198 | fn 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 | ||
| 215 | fn 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 | ||
| 249 | const testing = std.testing; | |
| 250 | ||
| 251 | test "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 | ||
| 263 | test "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 | ||
| 275 | test "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 | ||
| 288 | test "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 | } | |
| @@ -3,6 +3,7 @@ const builtin = ; | ||
| 3 | 3 | const objc_runtime = ; |
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | pub const ios_async = ; | |
| 6 | 7 | const bridge_mobile = ; |
| 7 | 8 | const bridge_mobile_clipboard = ; |
| 8 | 9 | const bridge_mobile_haptics = ; |
| @@ -10,6 +11,11 @@ const bridge_mobile_device = ; | ||
| 10 | 11 | const bridge_mobile_system = ; |
| 11 | 12 | const bridge_mobile_display = ; |
| 12 | 13 | const bridge_mobile_storage = ; |
| 14 | const bridge_mobile_misc = ; | |
| 15 | const bridge_mobile_shortcuts = ; | |
| 16 | const bridge_mobile_securestore = ; | |
| 17 | const bridge_mobile_biometric = ; | |
| 18 | const bridge_mobile_permissions = ; | |
| 13 | 19 | |
| 14 | 20 | const objc = objc_runtime.objc; |
| 15 | 21 | |
| @@ -202,6 +208,11 @@ const mobile_bridges = .{ | ||
| 202 | 208 | bridge_mobile_system.SystemBridge, |
| 203 | 209 | bridge_mobile_display.DisplayBridge, |
| 204 | 210 | 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, | |
| 205 | 216 | }; |
| 206 | 217 | |
| 207 | 218 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| @@ -355,6 +366,10 @@ fn buildShimError( | ||
| 355 | 366 | try out.append(allocator, '}'); |
| 356 | 367 | } |
| 357 | 368 | |
| 369 | test { | |
| 370 | _ = ios_async; | |
| 371 | } | |
| 372 | ||
| 358 | 373 | const testing = std.testing; |
| 359 | 374 | |
| 360 | 375 | test "an envelope without a type is rejected" { |
| @@ -31,6 +31,11 @@ const zig_sources = [_][]const u8{ | ||
| 31 | 31 | @embedFile("src/bridge_mobile_system.zig"), |
| 32 | 32 | @embedFile("src/bridge_mobile_display.zig"), |
| 33 | 33 | @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"), | |
| 34 | 39 | }; |
| 35 | 40 | |
| 36 | 41 | /// The action list lives in the `switch action` block, and nowhere else. |
| @@ -54,8 +59,10 @@ const dispatch_end = "func webView("; | ||
| 54 | 59 | /// exists to force. |
| 55 | 60 | /// |
| 56 | 61 | /// History: 105 after the vertical slice (getDeviceInfo); 86 after Tier 0 |
| 57 | /// landed clipboard, haptics, device, system, display, and storage. | |
| 58 | const 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. | |
| 65 | const max_not_yet_migrated: usize = 74; | |
| 59 | 66 | |
| 60 | 67 | fn dispatcherRegion() []const u8 { |
| 61 | 68 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |