also looking at this
iOS Phase 6: notifications, background tasks, Watch — ratchet 70 → 64
#88
10 files
+3613
-12
| @@ -90,7 +90,7 @@ LAUNCH_PID=$! | ||
| 90 | 90 | # The round trip is fast, but a cold simulator is not. Poll rather than sleep a |
| 91 | 91 | # fixed amount, so a slow boot does not read as a failure. |
| 92 | 92 | for _ in $(seq 1 60); do |
| 93 | if grep -q 'i=12' "$LOG" 2>/dev/null && grep -q 'i=10' "$LOG" 2>/dev/null; then sleep 1; break; fi | |
| 93 | if grep -q 'i=12' "$LOG" 2>/dev/null && grep -q 'i=14' "$LOG" 2>/dev/null; then sleep 1; break; fi | |
| 94 | 94 | sleep 1 |
| 95 | 95 | done |
| 96 | 96 | kill "$LAUNCH_PID" 2>/dev/null || true |
| @@ -158,4 +158,8 @@ C12="$(count 12)" | ||
| 158 | 158 | [ "$C12" -ge 1 ] || { echo "FAIL: the SQLite round trip never returned the bound value"; exit 1; } |
| 159 | 159 | echo "ok: dbExecute/dbQuery round-tripped through in-process SQLite (i=12 seen ${C12}x)" |
| 160 | 160 | |
| 161 | C14="$(count 14)" | |
| 162 | [ "$C14" -ge 1 ] || { echo "FAIL: the async array reply never reached the page"; exit 1; } | |
| 163 | echo "ok: async NSArray completion delivered via ios_async (i=14 seen ${C14}x)" | |
| 164 | ||
| 161 | 165 | echo "PASS" |
| @@ -30,7 +30,7 @@ const char craft_slice_page[] = | ||
| 30 | 30 | "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
| 31 | 31 | "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" |
| 32 | 32 | "</head><body><h1 id=\"s\">craft slice</h1><script>\n" |
| 33 | "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false;\n" | |
| 33 | "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n" | |
| 34 | 34 | "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n" |
| 35 | 35 | "\n" |
| 36 | 36 | "// Read before anything else touches it: the user script is installed at\n" |
| @@ -85,6 +85,14 @@ const char craft_slice_page[] = | ||
| 85 | 85 | " sentDb = true;\n" |
| 86 | 86 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:12});\n" |
| 87 | 87 | " }\n" |
| 88 | " // getPendingNotifications replies a BARE array (fragmentsAllowed), and\n" | |
| 89 | " // reaching it at all proves the async escape hatch: an NSArray completion\n" | |
| 90 | " // shaped by the module, delivered through ios_async's main-queue hop with\n" | |
| 91 | " // the request id captured back at dispatch.\n" | |
| 92 | " if (!sentNotif && action === 'getPendingNotifications' && Array.isArray(payload)) {\n" | |
| 93 | " sentNotif = true;\n" | |
| 94 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:14});\n" | |
| 95 | " }\n" | |
| 88 | 96 | " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n" |
| 89 | 97 | " sentTierZero = true;\n" |
| 90 | 98 | " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n" |
| @@ -108,6 +116,7 @@ const char craft_slice_page[] = | ||
| 108 | 116 | " // not formatted), then read it back through dbQuery.\n" |
| 109 | 117 | " bridge.postMessage({t:'mobile', a:'dbExecute', d: JSON.stringify({sql:'CREATE TABLE IF NOT EXISTS slice (k TEXT PRIMARY KEY, v TEXT)'}), i:40});\n" |
| 110 | 118 | " bridge.postMessage({t:'mobile', a:'dbExecute', d: JSON.stringify({sql:'INSERT OR REPLACE INTO slice (k,v) VALUES (?,?)', params:['k1',\"v'1\"]}), i:42});\n" |
| 119 | " bridge.postMessage({t:'mobile', a:'getPendingNotifications', i:50});\n" | |
| 111 | 120 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 112 | 121 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 113 | 122 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| @@ -440,6 +440,15 @@ pub fn build(b: *std.Build) void { | ||
| 440 | 440 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_notifcancel.zig", .{ |
| 441 | 441 | .root_source_file = b.path("src/bridge_mobile_notifcancel.zig"), |
| 442 | 442 | }); |
| 443 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_notifications.zig", .{ | |
| 444 | .root_source_file = b.path("src/bridge_mobile_notifications.zig"), | |
| 445 | }); | |
| 446 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_bgtasks.zig", .{ | |
| 447 | .root_source_file = b.path("src/bridge_mobile_bgtasks.zig"), | |
| 448 | }); | |
| 449 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_watch.zig", .{ | |
| 450 | .root_source_file = b.path("src/bridge_mobile_watch.zig"), | |
| 451 | }); | |
| 443 | 452 | |
| 444 | 453 | const menubar_tests = b.addTest(.{ |
| 445 | 454 | .root_module = b.createModule(.{ |
packages/zig/src/bridge_mobile_bgtasks.zigadded+1243-0
| @@ -0,0 +1,1243 @@ | ||
| 1 | //! The background-task actions of the `mobile` namespace: | |
| 2 | //! `scheduleBackgroundTask`, `cancelBackgroundTask`, `cancelAllBackgroundTasks`. | |
| 3 | //! | |
| 4 | //! One JS surface reaches these: `craft.backgroundTask`, whose four methods | |
| 5 | //! post `{action, taskId, …, callbackId}`. None of them goes through | |
| 6 | //! `craft._invoke`, so none of them has the 30s timeout — each is a raw `new | |
| 7 | //! Promise` stored into `_callbacks`. A dropped message parks the page | |
| 8 | //! forever, which is why every path here ends in a reply or an explicit error. | |
| 9 | //! | |
| 10 | //! ## What is carried across exactly, because it is the observable contract | |
| 11 | //! | |
| 12 | //! - **The identifier field is `taskId`** — that is what the injected JS | |
| 13 | //! posts and what the shim reads (`body["taskId"]`). The `callbackId` in | |
| 14 | //! the same payload is correlation plumbing the `{t,a,d,i}` envelope | |
| 15 | //! replaces; it is ignored, not consumed. | |
| 16 | //! - **The replies are objects, not bare fragments.** Swift resolves | |
| 17 | //! `["taskId": fullTaskId, "scheduled": true]` and | |
| 18 | //! `["taskId": fullTaskId, "cancelled": true]`, and — for cancel-all, the | |
| 19 | //! one reply of the three that differs in shape — `["cancelled": true]` | |
| 20 | //! with **no `taskId` key**. `test-bridges.html` prints | |
| 21 | //! `JSON.stringify(result)` for all three, so every key is observable. | |
| 22 | //! (`craft.d.ts` under-declares these as `{ scheduled: boolean }` and | |
| 23 | //! friends; the runtime objects are the wider ones, and the wider ones are | |
| 24 | //! what ship.) | |
| 25 | //! - **The `fullTaskId` prefix is contract.** All three id-carrying paths | |
| 26 | //! build `"<bundle identifier>.<taskId>"` and the *prefixed* id is what the | |
| 27 | //! reply names. A page that scheduled `craft-sync` gets back | |
| 28 | //! `com.acme.app.craft-sync`, and cancelling needs the same prefix applied | |
| 29 | //! to the same short name — so the join is a pure function pinned by a test | |
| 30 | //! rather than a format string written out three times. | |
| 31 | //! - **Cancelling an unknown identifier is a no-op that still resolves.** | |
| 32 | //! `cancelTaskRequestWithIdentifier:` ignores what it does not have, and so | |
| 33 | //! does Swift. Idempotent, like the notification cancels next door. | |
| 34 | //! - **An empty `taskId` passes through**, as it does in Swift: the shim | |
| 35 | //! hands `"<bundleid>."` to the scheduler and resolves. Bug-compatibility | |
| 36 | //! with the shim is the contract while the shim still exists. | |
| 37 | //! | |
| 38 | //! ## Deliberate divergences, each with its reason | |
| 39 | //! | |
| 40 | //! **`registerBackgroundTask` is not served here at all.** It is absent from | |
| 41 | //! the `A` block, so `handleMessage` answers `UnknownAction` and | |
| 42 | //! `ios_dispatch` falls the action through to the Swift shim, which is the | |
| 43 | //! only thing that answers it at all today. Every option here is bad; this is | |
| 44 | //! the reasoning for the one that was picked: | |
| 45 | //! | |
| 46 | //! 1. `BGTaskScheduler.register(forTaskWithIdentifier:using:launchHandler:)` | |
| 47 | //! must be called **before the app finishes launching**. A page-triggered | |
| 48 | //! call is by definition after `didFinishLaunchingWithOptions:` returned, | |
| 49 | //! and registering late raises `NSInternalInconsistencyException`, which | |
| 50 | //! as an uncaught Objective-C exception is an uncatchable SIGABRT — not | |
| 51 | //! an error Zig could map and report. | |
| 52 | //! 2. The launch handler fires minutes-to-hours later in a **new process | |
| 53 | //! launch**, where `ios_dispatch.global_webview` is null and | |
| 54 | //! `sendResultToJS` reaches `error.NoWebView`. Delivering it to a page | |
| 55 | //! needs an event channel iOS craft does not have. | |
| 56 | //! 3. `ios_async` cannot help: its ticket pool is one-shot request-to-reply | |
| 57 | //! and its global blocks fire once inside the same process. A launch | |
| 58 | //! handler has to survive process death. | |
| 59 | //! 4. `.status = .unavailable` dispatches and refuses, and Zig cannot do the | |
| 60 | //! work either way, so the choice is only about which wrong answer the | |
| 61 | //! page gets. Omission was chosen so that a config which *does* enable | |
| 62 | //! background tasks keeps behaving exactly as it does today, and because | |
| 63 | //! an action Zig never claims stays counted as not-yet-migrated by the | |
| 64 | //! conformance ratchet — `.unavailable` would let it read as handled. | |
| 65 | //! | |
| 66 | //! Be clear about what omission leaves standing, because neither branch of the | |
| 67 | //! shim is correct and this is the weak half of the trade: | |
| 68 | //! | |
| 69 | //! - With `config.enableBackgroundTasks` at its **default `false`**, the | |
| 70 | //! shim's `case "registerBackgroundTask":` arm has no `else`, so nothing | |
| 71 | //! replies at all and the untimed promise **hangs forever**. Falling | |
| 72 | //! through preserves that hang; `.unavailable` would at least reject. | |
| 73 | //! - With the gate on, the shim resolves `["taskId": …, "registered": true]` | |
| 74 | //! without ever calling `BGTaskScheduler.register` — it inserts into a | |
| 75 | //! `Set` that is read nowhere (`CraftApp.swift`, `registeredBackgroundTasks`, | |
| 76 | //! written once at one site and never read). So the resolve is a | |
| 77 | //! fabrication: no launch handler exists and none ever fires. | |
| 78 | //! | |
| 79 | //! Both are `CraftApp.swift` bugs, and the fix is Swift-side — an `else` arm | |
| 80 | //! that rejects with `CAPABILITY_DISABLED`, and either a real registration at | |
| 81 | //! launch or an honest rejection. If that fix does not land, reconsider this | |
| 82 | //! omission: a `.unavailable` refusal beats a promise that never settles. | |
| 83 | //! | |
| 84 | //! **`requiresNetwork` and `requiresCharging` are honoured, not dropped.** The | |
| 85 | //! shim takes both parameters and then never references them: it always builds | |
| 86 | //! a `BGAppRefreshTaskRequest`, which has no such properties. A page that asks | |
| 87 | //! for `requiresCharging: true` is told its constraint was accepted. Rule 2 | |
| 88 | //! says a field the page sent is never dropped, so this module builds a | |
| 89 | //! `BGProcessingTaskRequest` — which does have `requiresNetworkConnectivity` | |
| 90 | //! and `requiresExternalPower` — whenever either flag is true, and the plain | |
| 91 | //! `BGAppRefreshTaskRequest` when both are false. Three things make this the | |
| 92 | //! safe divergence rather than a risky one: craft's generated Info.plist | |
| 93 | //! declares `UIBackgroundModes` = `processing` and *not* `fetch`, so | |
| 94 | //! processing is the type a craft app is actually configured for; nothing | |
| 95 | //! works on device today anyway (below); and the alternative that preserves | |
| 96 | //! the shim's behaviour exactly is the one that lies to the caller. | |
| 97 | //! | |
| 98 | //! **Malformed input errors rather than hanging or silently defaulting.** The | |
| 99 | //! shim's `body["taskId"] as? String` failing replies nothing at all, and its | |
| 100 | //! `as? Double ?? 900` turns `delay: "soon"` into fifteen minutes the page | |
| 101 | //! never asked for. A *missing* field takes Swift's documented default (900 / | |
| 102 | //! false / false, the same defaults the injected JS already applies before | |
| 103 | //! posting); a field that is **present with the wrong type** is refused, never | |
| 104 | //! coerced. | |
| 105 | //! | |
| 106 | //! **The `config.enableBackgroundTasks` gate has no Zig mirror.** It appears | |
| 107 | //! nowhere in `packages/zig/src`, so the three actions here are served | |
| 108 | //! unconditionally — the securestore and notifcancel modules document the same | |
| 109 | //! choice. In an app that left the gate at its default `false` this changes a | |
| 110 | //! hang into an answer: `schedule` now reaches the scheduler and reports | |
| 111 | //! whatever it says (a rejection, until the plist gains | |
| 112 | //! `BGTaskSchedulerPermittedIdentifiers`), and the cancels report the no-op | |
| 113 | //! they perform. Nothing is granted that the page could not already reach with | |
| 114 | //! the gate on, and the actions only ever touch this app's own queue. | |
| 115 | //! | |
| 116 | //! ## Guards, in the order they fire | |
| 117 | //! | |
| 118 | //! Parsing and validation run first and touch no Objective-C, so a payload | |
| 119 | //! this module must refuse is refused identically on Linux, on a host, and on | |
| 120 | //! a device — which is what makes the pure tests below binding for the device | |
| 121 | //! build. | |
| 122 | //! | |
| 123 | //! Then the **bundle-identifier guard**, before anything reaches | |
| 124 | //! `BGTaskScheduler`. Unlike `[UNUserNotificationCenter | |
| 125 | //! currentNotificationCenter]`, which is verified to raise | |
| 126 | //! `NSInternalInconsistencyException` in a bundle-less process, | |
| 127 | //! `+[BGTaskScheduler sharedScheduler]` has been measured there and is *safe*: | |
| 128 | //! it hands back a scheduler, and a subsequent `submitTaskRequest:error:` | |
| 129 | //! answers `NO` with `BGTaskSchedulerErrorDomain` code 3 and a userInfo of | |
| 130 | //! `Unrecognized Identifier`. So this guard is **not** a SIGABRT guard — do | |
| 131 | //! not copy that reasoning from the notifcancel sibling. It exists for the | |
| 132 | //! reason below, and it costs nothing because the identifier is needed for the | |
| 133 | //! reply's prefix regardless. | |
| 134 | //! | |
| 135 | //! It also costs one deliberate divergence: Swift falls back to the literal | |
| 136 | //! `"com.craft.app"` when `Bundle.main.bundleIdentifier` is nil and carries on. | |
| 137 | //! This module refuses with `NoBundleIdentifier` instead. A process with no | |
| 138 | //! bundle identifier has no app identity, so `"com.craft.app.<taskId>"` names a | |
| 139 | //! task belonging to an app that is not running: `submit` under it is refused | |
| 140 | //! by the scheduler anyway (measured: code 3), and a `cancel` under it would | |
| 141 | //! resolve `cancelled: true` having addressed an identifier the caller does not | |
| 142 | //! own — fabricated success, which rule 1 outranks bug-compatibility. | |
| 143 | //! `default_bundle_prefix` below keeps the shim's spelling recorded, and a test | |
| 144 | //! pins that the join produces byte-identical output given that prefix, so if | |
| 145 | //! the refusal is ever traded back for parity the string is already right. In a | |
| 146 | //! shipped `.app` the fallback is unreachable anyway: `CFBundleIdentifier` is | |
| 147 | //! always present. | |
| 148 | //! | |
| 149 | //! Finally `objc_getClass("BGTaskScheduler")`, which returns **null in the | |
| 150 | //! host test binaries** — `build.zig` links Cocoa, WebKit, CoreFoundation, | |
| 151 | //! CoreGraphics, CoreMIDI and Security into the iOS test artifacts and never | |
| 152 | //! BackgroundTasks. In the shipped app the class is present only because | |
| 153 | //! `CraftApp.swift` does `import BackgroundTasks` and Xcode auto-links it; | |
| 154 | //! frameworks are linked at the app level in Xcode, not in this static | |
| 155 | //! library, so deleting that import post-migration would silently null the | |
| 156 | //! class and every action here would start answering `ClassNotFound`. | |
| 157 | //! | |
| 158 | //! ## What cannot work on device yet, in any language | |
| 159 | //! | |
| 160 | //! `BGTaskSchedulerPermittedIdentifiers` appears nowhere in the repo, and | |
| 161 | //! `renderBackgroundModes` emits only `UIBackgroundModes`. Without the | |
| 162 | //! identifier in the plist, `submitTaskRequest:error:` returns `NO` with | |
| 163 | //! `BGTaskSchedulerErrorDomain` code 3 (`NotPermitted`). That is a real, | |
| 164 | //! reported failure and not a fabrication, so this module serves the action | |
| 165 | //! and logs the NSError domain and code — but the plist generator needs a | |
| 166 | //! separate fix before `scheduleBackgroundTask` can succeed on hardware. On | |
| 167 | //! the simulator a submitted task additionally never launches on its own; it | |
| 168 | //! needs the `_simulateLaunchForTaskWithIdentifier:` debugger poke. | |
| 169 | //! | |
| 170 | //! ## Why `ios_async` is not imported | |
| 171 | //! | |
| 172 | //! Nothing here is asynchronous. `submitTaskRequest:error:` is a synchronous | |
| 173 | //! BOOL with an NSError out-param — no completion handler — and both cancels | |
| 174 | //! are synchronous voids. Acquiring a ticket for a reply that is already on | |
| 175 | //! the dispatch frame's stack would add a pool slot, a main-queue hop, and a | |
| 176 | //! pool-exhaustion failure mode to buy nothing. | |
| 177 | ||
| 178 | const std = ; | |
| 179 | const builtin = ; | |
| 180 | const capabilities = ; | |
| 181 | const bridge_error = ; | |
| 182 | const objc_runtime = ; | |
| 183 | ||
| 184 | const objc = objc_runtime.objc; | |
| 185 | const BridgeError = bridge_error.BridgeError; | |
| 186 | ||
| 187 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the | |
| 188 | /// notifcancel and securestore precedent: `objc_runtime.objc` is an empty | |
| 189 | /// struct off Darwin and signatures are analysed even where a comptime guard | |
| 190 | /// prunes the body, so naming `objc.id` here would break the Linux build. A | |
| 191 | /// single optional pointer, never `?objc.id` — a double optional is illegal in | |
| 192 | /// `callconv(.c)`. | |
| 193 | const Id = ?*anyopaque; | |
| 194 | ||
| 195 | /// Likewise for `objc.SEL`, needed in the hand-rolled `objc_msgSend` casts | |
| 196 | /// below: `objc_runtime.zig` has no sender for `BOOL (id, SEL, id, NSError **)` | |
| 197 | /// nor for a zero-argument `NSInteger`. | |
| 198 | const Sel = ?*anyopaque; | |
| 199 | ||
| 200 | /// The action names, spelled exactly as the Swift `case` labels spell them. | |
| 201 | /// `test/ios_conformance_test.zig` matches the two lists by string in both | |
| 202 | /// directions. | |
| 203 | /// | |
| 204 | /// registerBackgroundTask is absent on purpose — see the module header. The | |
| 205 | /// conformance ratchet only counts down, so an omitted action stays counted as | |
| 206 | /// not-yet-migrated, which is exactly the honest accounting for it. | |
| 207 | pub const A = struct { | |
| 208 | pub const schedule_background_task = "scheduleBackgroundTask"; | |
| 209 | pub const cancel_background_task = "cancelBackgroundTask"; | |
| 210 | pub const cancel_all_background_tasks = "cancelAllBackgroundTasks"; | |
| 211 | }; | |
| 212 | ||
| 213 | /// All three `.result`: each Swift path resolves a callback, and all three JS | |
| 214 | /// promises are the untimed legacy kind — `.none` here would strand a caller | |
| 215 | /// forever, not for thirty seconds. | |
| 216 | /// | |
| 217 | /// All three `.live`. Submitting and cancelling need no permission prompt and | |
| 218 | /// no completion handler; the plist gap that makes `submit` fail on device is | |
| 219 | /// a reported failure, not an action that cannot be reached, so it is not | |
| 220 | /// `.unavailable`. | |
| 221 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 222 | .{ .name = A.schedule_background_task, .reply = .result }, | |
| 223 | .{ .name = A.cancel_background_task, .reply = .result }, | |
| 224 | .{ .name = A.cancel_all_background_tasks, .reply = .result }, | |
| 225 | }; | |
| 226 | ||
| 227 | /// Swift's default when `Bundle.main.bundleIdentifier` is nil. Recorded rather | |
| 228 | /// than used: this module refuses in that process instead (module header). A | |
| 229 | /// test pins that `joinIdentifier` given this prefix produces byte-identical | |
| 230 | /// output to the shim, so the parity trade stays one line away. | |
| 231 | const default_bundle_prefix = "com.craft.app"; | |
| 232 | ||
| 233 | /// Swift's `body["delay"] as? Double ?? 900`, and the injected JS's | |
| 234 | /// `options.delay || 900` — fifteen minutes, applied only when the field is | |
| 235 | /// *absent*. | |
| 236 | const default_delay_seconds: f64 = 900; | |
| 237 | ||
| 238 | /// The boolean key each reply object carries beside `taskId`. Named so the | |
| 239 | /// reply builder cannot be handed a typo and so the cancel-all fragment can be | |
| 240 | /// checked against the same spelling. | |
| 241 | const scheduled_key = "scheduled"; | |
| 242 | const cancelled_key = "cancelled"; | |
| 243 | ||
| 244 | /// The one reply with no `taskId` key: Swift's | |
| 245 | /// `resolveCallback(callbackId, result: ["cancelled": true])`. Static, so | |
| 246 | /// cancel-all allocates nothing. | |
| 247 | const cancel_all_fragment = "{\"cancelled\":true}"; | |
| 248 | ||
| 249 | /// Which handler an action selects, split out from `handleMessage` so the | |
| 250 | /// table-versus-dispatch agreement is assertable on a host. The split is | |
| 251 | /// load-bearing for `cancelAllBackgroundTasks`: it has no payload and | |
| 252 | /// therefore no validation step, so a test that *called* it to prove routing | |
| 253 | /// would — in a process that does have a bundle identifier — cancel whatever | |
| 254 | /// background work the hosting app had pending. | |
| 255 | const Route = enum { schedule, cancel_one, cancel_all }; | |
| 256 | ||
| 257 | fn routeFor(action: []const u8) ?Route { | |
| 258 | if (std.mem.eql(u8, action, A.schedule_background_task)) return .schedule; | |
| 259 | if (std.mem.eql(u8, action, A.cancel_background_task)) return .cancel_one; | |
| 260 | if (std.mem.eql(u8, action, A.cancel_all_background_tasks)) return .cancel_all; | |
| 261 | return null; | |
| 262 | } | |
| 263 | ||
| 264 | pub const BgTasksBridge = struct { | |
| 265 | allocator: std.mem.Allocator, | |
| 266 | ||
| 267 | const Self = @This(); | |
| 268 | ||
| 269 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 270 | return .{ .allocator = allocator }; | |
| 271 | } | |
| 272 | ||
| 273 | pub fn deinit(_: *Self) void {} | |
| 274 | ||
| 275 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 276 | const route = routeFor(action) orelse return BridgeError.UnknownAction; | |
| 277 | // Exhaustive, so a `Route` without a handler is a compile error. | |
| 278 | return switch (route) { | |
| 279 | .schedule => self.scheduleBackgroundTask(data), | |
| 280 | .cancel_one => self.cancelBackgroundTask(data), | |
| 281 | .cancel_all => self.cancelAllBackgroundTasks(), | |
| 282 | }; | |
| 283 | } | |
| 284 | ||
| 285 | /// Submit one background-task request. Resolves the object | |
| 286 | /// `{"taskId":"<prefixed>","scheduled":true}`. | |
| 287 | /// | |
| 288 | /// Validation runs before any Objective-C, then the bundle guard, then the | |
| 289 | /// scheduler — so the only way to reach `submitTaskRequest:error:` is with | |
| 290 | /// a payload that was fully understood and a process that is a real app. | |
| 291 | fn scheduleBackgroundTask(self: *Self, data: []const u8) !void { | |
| 292 | var parsed = try parsePayload(self.allocator, data); | |
| 293 | defer parsed.deinit(); | |
| 294 | ||
| 295 | const request = try parseScheduleRequest(parsed.value); | |
| 296 | ||
| 297 | const prefix = try requireBundlePrefix(); | |
| 298 | const full_task_id = try joinIdentifier(self.allocator, prefix, request.task_id); | |
| 299 | defer self.allocator.free(full_task_id); | |
| 300 | ||
| 301 | const sched = try scheduler(); | |
| 302 | try submitRequest(self.allocator, sched, full_task_id, request); | |
| 303 | ||
| 304 | const reply = try taskReply(self.allocator, full_task_id, scheduled_key); | |
| 305 | defer self.allocator.free(reply); | |
| 306 | bridge_error.sendResultToJS(self.allocator, A.schedule_background_task, reply); | |
| 307 | } | |
| 308 | ||
| 309 | /// Cancel one pending request by identifier. Resolves | |
| 310 | /// `{"taskId":"<prefixed>","cancelled":true}` whether or not anything | |
| 311 | /// matched — `cancelTaskRequestWithIdentifier:` ignores what it does not | |
| 312 | /// have, and so does Swift. | |
| 313 | fn cancelBackgroundTask(self: *Self, data: []const u8) !void { | |
| 314 | var parsed = try parsePayload(self.allocator, data); | |
| 315 | defer parsed.deinit(); | |
| 316 | ||
| 317 | const task_id = try parseTaskId(parsed.value); | |
| 318 | ||
| 319 | const prefix = try requireBundlePrefix(); | |
| 320 | const full_task_id = try joinIdentifier(self.allocator, prefix, task_id); | |
| 321 | defer self.allocator.free(full_task_id); | |
| 322 | ||
| 323 | const sched = try scheduler(); | |
| 324 | try cancelIdentifier(self.allocator, sched, full_task_id); | |
| 325 | ||
| 326 | const reply = try taskReply(self.allocator, full_task_id, cancelled_key); | |
| 327 | defer self.allocator.free(reply); | |
| 328 | bridge_error.sendResultToJS(self.allocator, A.cancel_background_task, reply); | |
| 329 | } | |
| 330 | ||
| 331 | /// Cancel every pending request. The payload is ignored, as Swift ignores | |
| 332 | /// the body: the injected JS posts the action alone and | |
| 333 | /// `ios_dispatch.payloadOf` hands this `"{}"` for an absent `d`. An | |
| 334 | /// already-empty queue still resolves — idempotent, as in Swift. | |
| 335 | /// | |
| 336 | /// The bundle guard still runs even though no identifier is built from it: | |
| 337 | /// this touches the scheduler, and the guard is about what is safe to | |
| 338 | /// touch, not about what the reply says. | |
| 339 | fn cancelAllBackgroundTasks(self: *Self) !void { | |
| 340 | _ = try requireBundlePrefix(); | |
| 341 | ||
| 342 | const sched = try scheduler(); | |
| 343 | try cancelAll(sched); | |
| 344 | ||
| 345 | bridge_error.sendResultToJS(self.allocator, A.cancel_all_background_tasks, cancel_all_fragment); | |
| 346 | } | |
| 347 | }; | |
| 348 | ||
| 349 | // ============================================================================= | |
| 350 | // Payload parsing — pure, so every outcome the shim collapsed into a hang or a | |
| 351 | // silent default is pinned by a host test. | |
| 352 | // ============================================================================= | |
| 353 | ||
| 354 | /// Everything `scheduleBackgroundTask` needs, with the shim's defaults already | |
| 355 | /// applied for absent fields. Slices borrow from the parsed payload and are | |
| 356 | /// copied before it is freed. | |
| 357 | const ScheduleRequest = struct { | |
| 358 | task_id: []const u8, | |
| 359 | delay: f64, | |
| 360 | requires_network: bool, | |
| 361 | requires_charging: bool, | |
| 362 | }; | |
| 363 | ||
| 364 | /// Which `BGTaskRequest` subclass a set of constraints needs. | |
| 365 | const RequestKind = enum { app_refresh, processing }; | |
| 366 | ||
| 367 | /// The divergence from the shim, isolated into one pure function so it is | |
| 368 | /// pinned by a test rather than buried in an ObjC call sequence. | |
| 369 | /// | |
| 370 | /// `BGAppRefreshTaskRequest` has no constraint properties at all — the shim | |
| 371 | /// accepts both flags and then always builds one, so both are silently | |
| 372 | /// dropped. `BGProcessingTaskRequest` is the subclass that carries | |
| 373 | /// `requiresNetworkConnectivity` and `requiresExternalPower`, and `processing` | |
| 374 | /// is also the only `UIBackgroundModes` value craft's Info.plist generator | |
| 375 | /// emits. So: any constraint at all means processing; no constraints keeps the | |
| 376 | /// shim's app-refresh request, which is the cheaper of the two to be granted. | |
| 377 | fn requestKindFor(requires_network: bool, requires_charging: bool) RequestKind { | |
| 378 | return if (requires_network or requires_charging) .processing else .app_refresh; | |
| 379 | } | |
| 380 | ||
| 381 | /// Parse `d`, distinguishing a bad payload from a failed allocation — telling | |
| 382 | /// the page INVALID_JSON about its own good JSON sends whoever debugs it to | |
| 383 | /// the wrong side of the bridge. | |
| 384 | fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) { | |
| 385 | return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) { | |
| 386 | error.OutOfMemory => return err, | |
| 387 | else => return BridgeError.InvalidJSON, | |
| 388 | }; | |
| 389 | } | |
| 390 | ||
| 391 | /// The `taskId` field, or the reason it cannot be used. | |
| 392 | /// | |
| 393 | /// The name is pinned on both sides of the migration: the injected JS posts | |
| 394 | /// `taskId` and the shim reads `body["taskId"]`. An empty string is *not* | |
| 395 | /// refused — Swift builds `"<bundleid>."` from it and resolves, and | |
| 396 | /// interchangeability with the shim is the contract while the shim exists. | |
| 397 | fn parseTaskId(payload: std.json.Value) ![]const u8 { | |
| 398 | const object = switch (payload) { | |
| 399 | .object => |o| o, | |
| 400 | else => return BridgeError.InvalidJSON, | |
| 401 | }; | |
| 402 | const field = object.get("taskId") orelse return BridgeError.MissingData; | |
| 403 | const task_id = switch (field) { | |
| 404 | .string => |s| s, | |
| 405 | // Swift's `as? String` fails here and replies nothing at all. A | |
| 406 | // coercion would be worse: it would schedule or cancel a stringified | |
| 407 | // something the page never named and report success for it. | |
| 408 | else => return BridgeError.InvalidParameter, | |
| 409 | }; | |
| 410 | try requireNulFree(task_id); | |
| 411 | return task_id; | |
| 412 | } | |
| 413 | ||
| 414 | /// The full `scheduleBackgroundTask` payload. `parseTaskId` has already proved | |
| 415 | /// the value is an object by the time the other three fields are read. | |
| 416 | fn parseScheduleRequest(payload: std.json.Value) !ScheduleRequest { | |
| 417 | const task_id = try parseTaskId(payload); | |
| 418 | const object = payload.object; | |
| 419 | ||
| 420 | return .{ | |
| 421 | .task_id = task_id, | |
| 422 | .delay = try parseDelay(object), | |
| 423 | .requires_network = try parseFlag(object, "requiresNetwork"), | |
| 424 | .requires_charging = try parseFlag(object, "requiresCharging"), | |
| 425 | }; | |
| 426 | } | |
| 427 | ||
| 428 | /// `delay`, in seconds, as an `NSTimeInterval`. | |
| 429 | /// | |
| 430 | /// Accepts `.integer` and `.float` because a JSON number arrives as either and | |
| 431 | /// Swift's `as? Double` takes both — the injected JS sends `options.delay || | |
| 432 | /// 900`, which is an integer for every caller that does not pass a fraction. | |
| 433 | /// | |
| 434 | /// `.number_string` is what `std.json` produces for a literal that overflows | |
| 435 | /// `i64` or parses to a non-finite float (`1e400`). Swift would hand the | |
| 436 | /// latter to `Date(timeIntervalSinceNow:)` as an infinity; there is no such | |
| 437 | /// instant, so it is refused rather than turned into an arbitrary date. | |
| 438 | /// | |
| 439 | /// A negative delay is accepted, as in Swift: `earliestBeginDate` in the past | |
| 440 | /// means eligible immediately, which is a coherent request. | |
| 441 | fn parseDelay(object: std.json.ObjectMap) !f64 { | |
| 442 | const field = object.get("delay") orelse return default_delay_seconds; | |
| 443 | const seconds: f64 = switch (field) { | |
| 444 | .integer => |i| @floatFromInt(i), | |
| 445 | .float => |f| f, | |
| 446 | .number_string => |s| std.fmt.parseFloat(f64, s) catch return BridgeError.InvalidParameter, | |
| 447 | // Present and not a number. Swift's `?? 900` would silently schedule | |
| 448 | // fifteen minutes the page never asked for. | |
| 449 | else => return BridgeError.InvalidParameter, | |
| 450 | }; | |
| 451 | if (!std.math.isFinite(seconds)) return BridgeError.InvalidParameter; | |
| 452 | return seconds; | |
| 453 | } | |
| 454 | ||
| 455 | /// `requiresNetwork` / `requiresCharging`. Absent is `false`, matching both | |
| 456 | /// Swift's `?? false` and the injected JS's `options.x || false`; present and | |
| 457 | /// not a boolean is refused rather than defaulted, because a page that sent | |
| 458 | /// something for a constraint field is asking for a constraint. | |
| 459 | fn parseFlag(object: std.json.ObjectMap, name: []const u8) !bool { | |
| 460 | const field = object.get(name) orelse return false; | |
| 461 | return switch (field) { | |
| 462 | .bool => |b| b, | |
| 463 | else => BridgeError.InvalidParameter, | |
| 464 | }; | |
| 465 | } | |
| 466 | ||
| 467 | /// Refuse a `taskId` carrying the one byte it cannot survive. A NUL is | |
| 468 | /// reachable from a page as the legal JSON escape `\u0000`, which `std.json` | |
| 469 | /// decodes to the byte; `createNSString` (`stringWithUTF8String:`) would then | |
| 470 | /// truncate, and the request submitted or cancelled would carry a *different* | |
| 471 | /// identifier than the reply names. | |
| 472 | fn requireNulFree(s: []const u8) !void { | |
| 473 | if (std.mem.indexOfScalar(u8, s, 0) != null) return BridgeError.InvalidParameter; | |
| 474 | } | |
| 475 | ||
| 476 | // ============================================================================= | |
| 477 | // Identifier and reply shaping — pure, and the observable contract. | |
| 478 | // ============================================================================= | |
| 479 | ||
| 480 | /// `"<prefix>.<taskId>"`, Swift's | |
| 481 | /// `"\(Bundle.main.bundleIdentifier ?? "com.craft.app").\(taskId)"`. | |
| 482 | /// | |
| 483 | /// Pure and allocating, rather than a format string written out at all three | |
| 484 | /// call sites: this string is both what the scheduler is addressed with and | |
| 485 | /// what the reply names, and those two must not be able to drift. | |
| 486 | fn joinIdentifier(allocator: std.mem.Allocator, prefix: []const u8, task_id: []const u8) ![]u8 { | |
| 487 | return std.fmt.allocPrint(allocator, "{s}.{s}", .{ prefix, task_id }); | |
| 488 | } | |
| 489 | ||
| 490 | /// `{"taskId":"<escaped>","<flag>":true}` — the object shape both id-carrying | |
| 491 | /// replies use. | |
| 492 | /// | |
| 493 | /// The identifier is escaped with `appendJsonEscaped` because it embeds a | |
| 494 | /// page-controlled `taskId` and the whole reply is replayed into | |
| 495 | /// `evaluateJavaScript:` as source: a quote or backslash in a task name would | |
| 496 | /// otherwise break the page's promise resolution. Key order is not contract — | |
| 497 | /// Swift serialises an unordered `NSDictionary` without `.sortedKeys`. | |
| 498 | fn taskReply(allocator: std.mem.Allocator, full_task_id: []const u8, flag_key: []const u8) ![]u8 { | |
| 499 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 500 | errdefer out.deinit(allocator); | |
| 501 | ||
| 502 | try out.appendSlice(allocator, "{\"taskId\":\""); | |
| 503 | try bridge_error.appendJsonEscaped(allocator, &out, full_task_id); | |
| 504 | try out.appendSlice(allocator, "\",\""); | |
| 505 | try out.appendSlice(allocator, flag_key); | |
| 506 | try out.appendSlice(allocator, "\":true}"); | |
| 507 | ||
| 508 | return out.toOwnedSlice(allocator); | |
| 509 | } | |
| 510 | ||
| 511 | // ============================================================================= | |
| 512 | // Objective-C. Every function here opens with the Darwin guard, which prunes | |
| 513 | // the rest of its body off Darwin — `objc` is an empty struct there. | |
| 514 | // ============================================================================= | |
| 515 | ||
| 516 | /// The process's bundle identifier, or null when it has none. | |
| 517 | /// | |
| 518 | /// The returned slice points into the autoreleased NSString's UTF-8 buffer and | |
| 519 | /// is valid for the current autorelease-pool turn — the run-loop turn the | |
| 520 | /// `WKScriptMessageHandler` callback runs inside. Every caller copies it into | |
| 521 | /// an owned identifier before anything else happens. | |
| 522 | fn bundleIdentifier() !?[]const u8 { | |
| 523 | if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; | |
| 524 | ||
| 525 | const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; | |
| 526 | const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; | |
| 527 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return null; | |
| 528 | ||
| 529 | const sel_ident = objc.sel_registerName("bundleIdentifier") orelse return error.SelectorNotFound; | |
| 530 | const ns_ident = objc.msgSendId(bundle, sel_ident) orelse return null; | |
| 531 | ||
| 532 | const utf8 = objc.getNSStringUTF8(ns_ident) orelse return null; | |
| 533 | return std.mem.span(utf8); | |
| 534 | } | |
| 535 | ||
| 536 | /// The bundle identifier, or a refusal to address `BGTaskScheduler` without | |
| 537 | /// one. | |
| 538 | /// | |
| 539 | /// Not a crash guard: `+[BGTaskScheduler sharedScheduler]` has been measured in | |
| 540 | /// a bundle-less process and returns normally (module header). The refusal is | |
| 541 | /// about identity — see the header for why the shim's `?? "com.craft.app"` | |
| 542 | /// fallback would let a cancel resolve `cancelled: true` for an identifier the | |
| 543 | /// caller does not own. | |
| 544 | fn requireBundlePrefix() ![]const u8 { | |
| 545 | return (try bundleIdentifier()) orelse { | |
| 546 | std.log.warn( | |
| 547 | "background tasks: this process has no bundle identifier; " ++ | |
| 548 | "refusing rather than addressing BGTaskScheduler as '{s}'", | |
| 549 | .{default_bundle_prefix}, | |
| 550 | ); | |
| 551 | return error.NoBundleIdentifier; | |
| 552 | }; | |
| 553 | } | |
| 554 | ||
| 555 | /// `+[BGTaskScheduler sharedScheduler]`, guarded. A null class means | |
| 556 | /// BackgroundTasks.framework is not in the process — the normal answer in the | |
| 557 | /// host test binaries, and a link-configuration problem in an app — named | |
| 558 | /// rather than crashed on. | |
| 559 | fn scheduler() !Id { | |
| 560 | if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; | |
| 561 | ||
| 562 | const BGTaskScheduler = objc.objc_getClass("BGTaskScheduler") orelse return error.ClassNotFound; | |
| 563 | const sel_shared = objc.sel_registerName("sharedScheduler") orelse return error.SelectorNotFound; | |
| 564 | return objc.msgSendId(BGTaskScheduler, sel_shared) orelse error.NativeCallFailed; | |
| 565 | } | |
| 566 | ||
| 567 | /// Build the request, apply the constraints, and submit it. | |
| 568 | /// | |
| 569 | /// `submitTaskRequest:error:` is a synchronous `BOOL` with an `NSError **` | |
| 570 | /// out-param — no completion handler, so nothing here is asynchronous. The | |
| 571 | /// error object is read for the log only: `sendErrorToJS` carries an enum and | |
| 572 | /// not text, so Swift's `error.localizedDescription` cannot be reproduced | |
| 573 | /// verbatim, and the specifics survive in the device log instead — the | |
| 574 | /// `bridge_mobile_misc` precedent. | |
| 575 | fn submitRequest( | |
| 576 | allocator: std.mem.Allocator, | |
| 577 | sched: Id, | |
| 578 | full_task_id: []const u8, | |
| 579 | spec: ScheduleRequest, | |
| 580 | ) !void { | |
| 581 | if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; | |
| 582 | ||
| 583 | const kind = requestKindFor(spec.requires_network, spec.requires_charging); | |
| 584 | const class_name: [:0]const u8 = switch (kind) { | |
| 585 | .app_refresh => "BGAppRefreshTaskRequest", | |
| 586 | .processing => "BGProcessingTaskRequest", | |
| 587 | }; | |
| 588 | const RequestClass = objc.objc_getClass(class_name) orelse return error.ClassNotFound; | |
| 589 | ||
| 590 | // `createNSString` can fail (allocation, missing class) *and* can hand | |
| 591 | // back nil — `stringWithUTF8String:` returns nil for bytes it rejects. | |
| 592 | // `std.json` already validated UTF-8 and the NUL check ran, so a nil here | |
| 593 | // should not fire; unchecked it would be an uncatchable ObjC exception | |
| 594 | // inside `initWithIdentifier:`. | |
| 595 | const ns_identifier = (try objc.createNSString(full_task_id, allocator)) orelse | |
| 596 | return error.StringCreationFailed; | |
| 597 | ||
| 598 | const sel_init = objc.sel_registerName("initWithIdentifier:") orelse return error.SelectorNotFound; | |
| 599 | const raw = (try objc.alloc(RequestClass)) orelse return error.NativeCallFailed; | |
| 600 | // Owned: `alloc` + `init`, so it is released below. A nil from a failed | |
| 601 | // `init` has already released `raw` by ObjC convention, so there is | |
| 602 | // nothing to release on that path. | |
| 603 | const request = objc.msgSendId1(raw, sel_init, ns_identifier) orelse return error.NativeCallFailed; | |
| 604 | defer objc.release(request); | |
| 605 | ||
| 606 | // `+[NSDate dateWithTimeIntervalSinceNow:]` takes an `NSTimeInterval`, | |
| 607 | // i.e. a `double`. Passed as an `f64` so the msgSend cast puts it in a | |
| 608 | // float register; laundering it through an integer would land it in the | |
| 609 | // wrong register file and schedule a garbage date. | |
| 610 | const NSDate = objc.objc_getClass("NSDate") orelse return error.ClassNotFound; | |
| 611 | const sel_since_now = objc.sel_registerName("dateWithTimeIntervalSinceNow:") orelse | |
| 612 | return error.SelectorNotFound; | |
| 613 | const begin_date = objc.msgSendId1(NSDate, sel_since_now, spec.delay) orelse | |
| 614 | return error.NativeCallFailed; | |
| 615 | ||
| 616 | const sel_set_begin = objc.sel_registerName("setEarliestBeginDate:") orelse return error.SelectorNotFound; | |
| 617 | objc.msgSendVoid1(request, sel_set_begin, begin_date); | |
| 618 | ||
| 619 | if (kind == .processing) { | |
| 620 | // Only `BGProcessingTaskRequest` responds to these, and by | |
| 621 | // construction of `requestKindFor` the app-refresh branch is never | |
| 622 | // reached with a constraint set. `bool` is the right Zig type for | |
| 623 | // `BOOL`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is defined, | |
| 624 | // so `BOOL` is C99 `_Bool` (see `ios.zig`). | |
| 625 | const sel_network = objc.sel_registerName("setRequiresNetworkConnectivity:") orelse | |
| 626 | return error.SelectorNotFound; | |
| 627 | objc.msgSendVoid1(request, sel_network, spec.requires_network); | |
| 628 | ||
| 629 | const sel_power = objc.sel_registerName("setRequiresExternalPower:") orelse | |
| 630 | return error.SelectorNotFound; | |
| 631 | objc.msgSendVoid1(request, sel_power, spec.requires_charging); | |
| 632 | } | |
| 633 | ||
| 634 | const sel_submit = objc.sel_registerName("submitTaskRequest:error:") orelse return error.SelectorNotFound; | |
| 635 | const SubmitFn = *const fn (Id, Sel, Id, ?*Id) callconv(.c) bool; | |
| 636 | const submit: SubmitFn = @ptrCast(&objc.objc_msgSend); | |
| 637 | ||
| 638 | var ns_error: Id = null; | |
| 639 | if (submit(sched, sel_submit, request, &ns_error)) return; | |
| 640 | ||
| 641 | logSubmitFailure(full_task_id, kind, ns_error); | |
| 642 | return BridgeError.NativeCallFailed; | |
| 643 | } | |
| 644 | ||
| 645 | /// `-[BGTaskScheduler cancelTaskRequestWithIdentifier:]` — synchronous, void, | |
| 646 | /// and a no-op for an identifier that is not pending. | |
| 647 | fn cancelIdentifier(allocator: std.mem.Allocator, sched: Id, full_task_id: []const u8) !void { | |
| 648 | if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; | |
| 649 | ||
| 650 | const ns_identifier = (try objc.createNSString(full_task_id, allocator)) orelse | |
| 651 | return error.StringCreationFailed; | |
| 652 | ||
| 653 | const sel_cancel = objc.sel_registerName("cancelTaskRequestWithIdentifier:") orelse | |
| 654 | return error.SelectorNotFound; | |
| 655 | objc.msgSendVoid1(sched, sel_cancel, ns_identifier); | |
| 656 | } | |
| 657 | ||
| 658 | /// `-[BGTaskScheduler cancelAllTaskRequests]` — void, zero arguments. | |
| 659 | fn cancelAll(sched: Id) !void { | |
| 660 | if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform; | |
| 661 | ||
| 662 | const sel_cancel_all = objc.sel_registerName("cancelAllTaskRequests") orelse return error.SelectorNotFound; | |
| 663 | objc.msgSend(sched, sel_cancel_all); | |
| 664 | } | |
| 665 | ||
| 666 | /// The one thing an enum-shaped error reply cannot carry: which | |
| 667 | /// `BGTaskSchedulerErrorCode` came back. | |
| 668 | /// | |
| 669 | /// Worth spelling the codes out here, because code 3 is what every craft app | |
| 670 | /// gets today — the generated Info.plist has no | |
| 671 | /// `BGTaskSchedulerPermittedIdentifiers`, so no identifier is permitted and | |
| 672 | /// every submit is refused. | |
| 673 | fn logSubmitFailure(full_task_id: []const u8, kind: RequestKind, ns_error: Id) void { | |
| 674 | if (!builtin.target.os.tag.isDarwin()) return; | |
| 675 | ||
| 676 | if (ns_error == null) { | |
| 677 | std.log.warn( | |
| 678 | "background task '{s}' ({s}) was refused by BGTaskScheduler with no NSError", | |
| 679 | .{ full_task_id, @tagName(kind) }, | |
| 680 | ); | |
| 681 | return; | |
| 682 | } | |
| 683 | ||
| 684 | const domain = nsStringText(sendId(ns_error, "domain")) orelse "<unknown domain>"; | |
| 685 | const description = nsStringText(sendId(ns_error, "localizedDescription")) orelse "<no description>"; | |
| 686 | const code = nsInteger(ns_error, "code") orelse 0; | |
| 687 | ||
| 688 | std.log.warn( | |
| 689 | "background task '{s}' ({s}) submit failed: {s} {d} - {s}. " ++ | |
| 690 | "BGTaskSchedulerErrorDomain: 1=Unavailable, 2=TooManyPendingTaskRequests, " ++ | |
| 691 | "3=NotPermitted (identifier missing from BGTaskSchedulerPermittedIdentifiers)", | |
| 692 | .{ full_task_id, @tagName(kind), domain, code, description }, | |
| 693 | ); | |
| 694 | } | |
| 695 | ||
| 696 | /// `objc_msgSend` returning `id`, with the selector looked up by name and a | |
| 697 | /// null selector treated as a null result — log-path only, so a missing | |
| 698 | /// selector must not turn into an error that replaces the real one. | |
| 699 | fn sendId(target: Id, name: [:0]const u8) Id { | |
| 700 | if (!builtin.target.os.tag.isDarwin()) return null; | |
| 701 | const sel = objc.sel_registerName(name) orelse return null; | |
| 702 | return objc.msgSendId(target, sel); | |
| 703 | } | |
| 704 | ||
| 705 | /// A zero-argument `NSInteger` getter. `objc_runtime.zig` has no sender for | |
| 706 | /// this shape, hence the local type. | |
| 707 | fn nsInteger(target: Id, name: [:0]const u8) ?c_long { | |
| 708 | if (!builtin.target.os.tag.isDarwin()) return null; | |
| 709 | const sel = objc.sel_registerName(name) orelse return null; | |
| 710 | const Fn = *const fn (Id, Sel) callconv(.c) c_long; | |
| 711 | const func: Fn = @ptrCast(&objc.objc_msgSend); | |
| 712 | return func(target, sel); | |
| 713 | } | |
| 714 | ||
| 715 | /// An NSString's UTF-8 bytes as a Zig slice, valid for the current | |
| 716 | /// autorelease-pool turn. Used only to format a log line, which happens | |
| 717 | /// immediately. | |
| 718 | fn nsStringText(ns_string: Id) ?[]const u8 { | |
| 719 | if (!builtin.target.os.tag.isDarwin()) return null; | |
| 720 | if (ns_string == null) return null; | |
| 721 | const utf8 = objc.getNSStringUTF8(ns_string) orelse return null; | |
| 722 | return std.mem.span(utf8); | |
| 723 | } | |
| 724 | ||
| 725 | // ============================================================================= | |
| 726 | // Tests — host-only. Everything that decides what the page sees (routing, | |
| 727 | // field names, defaults, refusal reasons, the identifier prefix, the reply | |
| 728 | // objects) is pinned as pure logic, and the one Objective-C path a host can | |
| 729 | // reach — the bundle-identifier refusal — is exercised for real, because the | |
| 730 | // test runner is exactly the bundle-less process the guard exists for. The | |
| 731 | // scheduler calls themselves are never made from here: in a process that *did* | |
| 732 | // have a bundle identifier they would cancel that app's real pending work. | |
| 733 | // ============================================================================= | |
| 734 | ||
| 735 | const testing = std.testing; | |
| 736 | ||
| 737 | test "the declared actions are the ones the handler serves" { | |
| 738 | try testing.expectEqual(@as(usize, 3), capability_actions.len); | |
| 739 | try testing.expectEqualStrings(A.schedule_background_task, capability_actions[0].name); | |
| 740 | try testing.expectEqualStrings(A.cancel_background_task, capability_actions[1].name); | |
| 741 | try testing.expectEqualStrings(A.cancel_all_background_tasks, capability_actions[2].name); | |
| 742 | ||
| 743 | for (capability_actions) |decl| { | |
| 744 | try testing.expectEqual(capabilities.Reply.result, decl.reply); | |
| 745 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); | |
| 746 | try testing.expectEqual(@as(?[]const u8, null), decl.reason); | |
| 747 | } | |
| 748 | } | |
| 749 | ||
| 750 | test "the action names match the Swift case labels exactly" { | |
| 751 | // The conformance ratchet compares these against the `case "…":` labels in | |
| 752 | // `CraftApp.swift` in both directions; a prettier spelling would read as | |
| 753 | // Zig serving an action the spec does not have. | |
| 754 | try testing.expectEqualStrings("scheduleBackgroundTask", A.schedule_background_task); | |
| 755 | try testing.expectEqualStrings("cancelBackgroundTask", A.cancel_background_task); | |
| 756 | try testing.expectEqualStrings("cancelAllBackgroundTasks", A.cancel_all_background_tasks); | |
| 757 | } | |
| 758 | ||
| 759 | test "every declared action is one the dispatcher routes" { | |
| 760 | for (capability_actions) |decl| { | |
| 761 | if (routeFor(decl.name) == null) { | |
| 762 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 763 | return error.DeclaredActionDoesNotRoute; | |
| 764 | } | |
| 765 | } | |
| 766 | } | |
| 767 | ||
| 768 | test "every route the dispatcher has is a declared action" { | |
| 769 | // The other direction: a route with a handler and no declaration is an | |
| 770 | // action the page can call and the manifest denies exists. Each | |
| 771 | // declaration must claim a *distinct* route — counting alone would let two | |
| 772 | // rows share one route while another went undeclared. | |
| 773 | var claimed = std.mem.zeroes([std.enums.values(Route).len]bool); | |
| 774 | for (capability_actions) |decl| { | |
| 775 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 776 | const slot = @backingInt(route); | |
| 777 | if (claimed[slot]) { | |
| 778 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 779 | return error.TwoDeclarationsShareARoute; | |
| 780 | } | |
| 781 | claimed[slot] = true; | |
| 782 | } | |
| 783 | for (claimed, 0..) |taken, slot| { | |
| 784 | if (!taken) { | |
| 785 | std.debug.print( | |
| 786 | "route {s} has a handler but no capability_actions row\n", | |
| 787 | .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))}, | |
| 788 | ); | |
| 789 | return error.RouteNotDeclared; | |
| 790 | } | |
| 791 | } | |
| 792 | } | |
| 793 | ||
| 794 | test "registerBackgroundTask is deliberately not claimed, so it reaches the shim" { | |
| 795 | // The load-bearing omission. `ios_dispatch` routes to the first module that | |
| 796 | // does not answer `UnknownAction`; answering it here is what lets the | |
| 797 | // action fall through to the Swift shim that resolves it today. If this | |
| 798 | // ever starts routing, `craft.backgroundTask.register()` changes behaviour | |
| 799 | // — see the module header for why Zig cannot serve it honestly. | |
| 800 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 801 | defer bridge.deinit(); | |
| 802 | ||
| 803 | try testing.expectError( | |
| 804 | BridgeError.UnknownAction, | |
| 805 | bridge.handleMessage("registerBackgroundTask", "{\"taskId\":\"craft-sync\"}"), | |
| 806 | ); | |
| 807 | try testing.expect(routeFor("registerBackgroundTask") == null); | |
| 808 | ||
| 809 | for (capability_actions) |decl| { | |
| 810 | try testing.expect(!std.mem.eql(u8, decl.name, "registerBackgroundTask")); | |
| 811 | } | |
| 812 | } | |
| 813 | ||
| 814 | test "an action the namespace does not serve is reported, not ignored" { | |
| 815 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 816 | defer bridge.deinit(); | |
| 817 | ||
| 818 | try testing.expectError( | |
| 819 | BridgeError.UnknownAction, | |
| 820 | bridge.handleMessage("noSuchAction", "{}"), | |
| 821 | ); | |
| 822 | // Near misses — casing is how a real typo arrives. | |
| 823 | try testing.expectError( | |
| 824 | BridgeError.UnknownAction, | |
| 825 | bridge.handleMessage("schedulebackgroundtask", "{}"), | |
| 826 | ); | |
| 827 | // Singular, which is the plausible slip for the cancel-all name. | |
| 828 | try testing.expectError( | |
| 829 | BridgeError.UnknownAction, | |
| 830 | bridge.handleMessage("cancelAllBackgroundTask", "{}"), | |
| 831 | ); | |
| 832 | // The JS surface method names, which are not action names. | |
| 833 | try testing.expectError( | |
| 834 | BridgeError.UnknownAction, | |
| 835 | bridge.handleMessage("schedule", "{}"), | |
| 836 | ); | |
| 837 | try testing.expectError( | |
| 838 | BridgeError.UnknownAction, | |
| 839 | bridge.handleMessage("cancelAll", "{}"), | |
| 840 | ); | |
| 841 | // The neighbouring cancel actions this module must not claim: two modules | |
| 842 | // answering one action would make `ios_dispatch`'s first-match routing | |
| 843 | // order-dependent. | |
| 844 | try testing.expectError( | |
| 845 | BridgeError.UnknownAction, | |
| 846 | bridge.handleMessage("cancelNotification", "{}"), | |
| 847 | ); | |
| 848 | try testing.expectError( | |
| 849 | BridgeError.UnknownAction, | |
| 850 | bridge.handleMessage("cancelAllNotifications", "{}"), | |
| 851 | ); | |
| 852 | } | |
| 853 | ||
| 854 | test "a malformed payload is reported as bad JSON, not as a missing field" { | |
| 855 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 856 | defer bridge.deinit(); | |
| 857 | ||
| 858 | try testing.expectError( | |
| 859 | BridgeError.InvalidJSON, | |
| 860 | bridge.handleMessage(A.schedule_background_task, "{not json"), | |
| 861 | ); | |
| 862 | try testing.expectError( | |
| 863 | BridgeError.InvalidJSON, | |
| 864 | bridge.handleMessage(A.cancel_background_task, "{not json"), | |
| 865 | ); | |
| 866 | } | |
| 867 | ||
| 868 | fn parse(json: []const u8) !std.json.Parsed(std.json.Value) { | |
| 869 | return std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 870 | } | |
| 871 | ||
| 872 | fn expectTaskIdError(json: []const u8, expected: anyerror) !void { | |
| 873 | var parsed = try parse(json); | |
| 874 | defer parsed.deinit(); | |
| 875 | try testing.expectError(expected, parseTaskId(parsed.value)); | |
| 876 | } | |
| 877 | ||
| 878 | test "the identifier field the page sends is the one that is read" { | |
| 879 | // `{action:'scheduleBackgroundTask', taskId: taskId, …}` in the injected | |
| 880 | // JS; the shim reads `body["taskId"]`. A rename on either side of the | |
| 881 | // migration would make the two handlers read different payloads. | |
| 882 | var parsed = try parse("{\"taskId\":\"craft-sync\",\"callbackId\":\"cb_7\"}"); | |
| 883 | defer parsed.deinit(); | |
| 884 | try testing.expectEqualStrings("craft-sync", try parseTaskId(parsed.value)); | |
| 885 | ||
| 886 | // The plausible wrong names are *absences*, not aliases — a payload | |
| 887 | // carrying only them must read as missing, or a handler bound to the wrong | |
| 888 | // name would pass this suite. | |
| 889 | try expectTaskIdError("{\"id\":\"craft-sync\"}", BridgeError.MissingData); | |
| 890 | try expectTaskIdError("{\"identifier\":\"craft-sync\"}", BridgeError.MissingData); | |
| 891 | try expectTaskIdError("{\"task\":\"craft-sync\"}", BridgeError.MissingData); | |
| 892 | } | |
| 893 | ||
| 894 | test "a missing taskId is refused rather than defaulted" { | |
| 895 | // Swift's `let taskId = body["taskId"] as? String` fails here and replies | |
| 896 | // nothing at all — the promise has no timeout, so that is a hang forever. | |
| 897 | // An error is the deliberate divergence; a default would be worse still, | |
| 898 | // addressing an identifier the page never named. | |
| 899 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 900 | defer bridge.deinit(); | |
| 901 | ||
| 902 | try testing.expectError( | |
| 903 | BridgeError.MissingData, | |
| 904 | bridge.handleMessage(A.schedule_background_task, "{}"), | |
| 905 | ); | |
| 906 | try testing.expectError( | |
| 907 | BridgeError.MissingData, | |
| 908 | bridge.handleMessage(A.cancel_background_task, "{\"delay\":900}"), | |
| 909 | ); | |
| 910 | } | |
| 911 | ||
| 912 | test "a non-string taskId is refused, not coerced" { | |
| 913 | try expectTaskIdError("{\"taskId\":7}", BridgeError.InvalidParameter); | |
| 914 | try expectTaskIdError("{\"taskId\":null}", BridgeError.InvalidParameter); | |
| 915 | try expectTaskIdError("{\"taskId\":[\"craft-sync\"]}", BridgeError.InvalidParameter); | |
| 916 | try expectTaskIdError("{\"taskId\":true}", BridgeError.InvalidParameter); | |
| 917 | } | |
| 918 | ||
| 919 | test "a taskId with an embedded NUL is refused, not truncated" { | |
| 920 | // `stringWithUTF8String:` stops at the first NUL, so an unchecked | |
| 921 | // "syncevil" would submit or cancel the identifier ending in "sync" | |
| 922 | // while the reply named the full one the page sent. | |
| 923 | try expectTaskIdError("{\"taskId\":\"sync\\u0000evil\"}", BridgeError.InvalidParameter); | |
| 924 | } | |
| 925 | ||
| 926 | test "an empty taskId is accepted, matching the Swift shim" { | |
| 927 | // Swift builds "<bundleid>." from it, hands that to the scheduler, and | |
| 928 | // resolves. Interchangeability with the shim is the contract here. | |
| 929 | var parsed = try parse("{\"taskId\":\"\"}"); | |
| 930 | defer parsed.deinit(); | |
| 931 | try testing.expectEqualStrings("", try parseTaskId(parsed.value)); | |
| 932 | } | |
| 933 | ||
| 934 | test "a payload that is not an object is bad JSON, not a missing field" { | |
| 935 | try expectTaskIdError("[]", BridgeError.InvalidJSON); | |
| 936 | try expectTaskIdError("\"craft-sync\"", BridgeError.InvalidJSON); | |
| 937 | try expectTaskIdError("900", BridgeError.InvalidJSON); | |
| 938 | } | |
| 939 | ||
| 940 | test "the schedule payload is read field for field as the page posts it" { | |
| 941 | // The exact object `craft.backgroundTask.schedule('craft-sync', {delay: | |
| 942 | // 900, requiresNetwork: true, requiresCharging: false})` produces, minus | |
| 943 | // the callbackId the envelope replaces. This is `test-bridges.html`'s | |
| 944 | // Schedule button. | |
| 945 | var parsed = try parse( | |
| 946 | "{\"taskId\":\"craft-sync\",\"delay\":900,\"requiresNetwork\":true,\"requiresCharging\":false}", | |
| 947 | ); | |
| 948 | defer parsed.deinit(); | |
| 949 | ||
| 950 | const request = try parseScheduleRequest(parsed.value); | |
| 951 | try testing.expectEqualStrings("craft-sync", request.task_id); | |
| 952 | try testing.expectEqual(@as(f64, 900), request.delay); | |
| 953 | try testing.expect(request.requires_network); | |
| 954 | try testing.expect(!request.requires_charging); | |
| 955 | } | |
| 956 | ||
| 957 | test "delay arrives as an integer or a float and both are kept exactly" { | |
| 958 | // `as? Double` in Swift takes both, and JSON gives `std.json` an | |
| 959 | // `.integer` for `60` and a `.float` for `0.5`. | |
| 960 | { | |
| 961 | var parsed = try parse("{\"taskId\":\"t\",\"delay\":60}"); | |
| 962 | defer parsed.deinit(); | |
| 963 | try testing.expectEqual(@as(f64, 60), (try parseScheduleRequest(parsed.value)).delay); | |
| 964 | } | |
| 965 | { | |
| 966 | var parsed = try parse("{\"taskId\":\"t\",\"delay\":0.5}"); | |
| 967 | defer parsed.deinit(); | |
| 968 | try testing.expectEqual(@as(f64, 0.5), (try parseScheduleRequest(parsed.value)).delay); | |
| 969 | } | |
| 970 | { | |
| 971 | // Negative means "eligible immediately", which is what | |
| 972 | // `Date(timeIntervalSinceNow: -60)` means to iOS. Swift allows it. | |
| 973 | var parsed = try parse("{\"taskId\":\"t\",\"delay\":-60}"); | |
| 974 | defer parsed.deinit(); | |
| 975 | try testing.expectEqual(@as(f64, -60), (try parseScheduleRequest(parsed.value)).delay); | |
| 976 | } | |
| 977 | } | |
| 978 | ||
| 979 | test "an absent delay takes the fifteen minutes both sides already default to" { | |
| 980 | try testing.expectEqual(@as(f64, 900), default_delay_seconds); | |
| 981 | ||
| 982 | var parsed = try parse("{\"taskId\":\"craft-sync\"}"); | |
| 983 | defer parsed.deinit(); | |
| 984 | try testing.expectEqual(default_delay_seconds, (try parseScheduleRequest(parsed.value)).delay); | |
| 985 | } | |
| 986 | ||
| 987 | fn expectScheduleError(json: []const u8, expected: anyerror) !void { | |
| 988 | var parsed = try parse(json); | |
| 989 | defer parsed.deinit(); | |
| 990 | try testing.expectError(expected, parseScheduleRequest(parsed.value)); | |
| 991 | } | |
| 992 | ||
| 993 | test "a delay that is present and not a number is refused, not silently 900" { | |
| 994 | // Swift's `as? Double ?? 900` schedules fifteen minutes for every one of | |
| 995 | // these, which is a delay the page never asked for reported as success. | |
| 996 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":\"soon\"}", BridgeError.InvalidParameter); | |
| 997 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":null}", BridgeError.InvalidParameter); | |
| 998 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":true}", BridgeError.InvalidParameter); | |
| 999 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":[900]}", BridgeError.InvalidParameter); | |
| 1000 | } | |
| 1001 | ||
| 1002 | test "a delay with no representable instant is refused" { | |
| 1003 | // `1e400` overflows f64, so `std.json` hands back `.number_string`; there | |
| 1004 | // is no `Date(timeIntervalSinceNow: inf)` to schedule against. | |
| 1005 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":1e400}", BridgeError.InvalidParameter); | |
| 1006 | try expectScheduleError("{\"taskId\":\"t\",\"delay\":-1e400}", BridgeError.InvalidParameter); | |
| 1007 | ||
| 1008 | // An integer literal too wide for i64 also arrives as `.number_string`, | |
| 1009 | // but it *is* finite as a float and stays accepted — the refusal above is | |
| 1010 | // about the instant, not about the token shape. | |
| 1011 | var parsed = try parse("{\"taskId\":\"t\",\"delay\":99999999999999999999}"); | |
| 1012 | defer parsed.deinit(); | |
| 1013 | try testing.expect(std.math.isFinite((try parseScheduleRequest(parsed.value)).delay)); | |
| 1014 | } | |
| 1015 | ||
| 1016 | test "absent constraint flags are false, matching both sides" { | |
| 1017 | var parsed = try parse("{\"taskId\":\"craft-sync\"}"); | |
| 1018 | defer parsed.deinit(); | |
| 1019 | const request = try parseScheduleRequest(parsed.value); | |
| 1020 | try testing.expect(!request.requires_network); | |
| 1021 | try testing.expect(!request.requires_charging); | |
| 1022 | } | |
| 1023 | ||
| 1024 | test "a constraint flag that is present and not a boolean is refused" { | |
| 1025 | // A page that sent something for a constraint field is asking for a | |
| 1026 | // constraint; `?? false` would drop it and report the schedule succeeded. | |
| 1027 | try expectScheduleError("{\"taskId\":\"t\",\"requiresNetwork\":\"yes\"}", BridgeError.InvalidParameter); | |
| 1028 | try expectScheduleError("{\"taskId\":\"t\",\"requiresNetwork\":1}", BridgeError.InvalidParameter); | |
| 1029 | try expectScheduleError("{\"taskId\":\"t\",\"requiresCharging\":null}", BridgeError.InvalidParameter); | |
| 1030 | try expectScheduleError("{\"taskId\":\"t\",\"requiresCharging\":{}}", BridgeError.InvalidParameter); | |
| 1031 | } | |
| 1032 | ||
| 1033 | test "the constraint flags choose the request subclass that can carry them" { | |
| 1034 | // The divergence from the shim, pinned. The shim always builds an | |
| 1035 | // app-refresh request, which has neither property, so both flags are | |
| 1036 | // silently dropped; only `BGProcessingTaskRequest` has | |
| 1037 | // `requiresNetworkConnectivity` and `requiresExternalPower`. | |
| 1038 | try testing.expectEqual(RequestKind.app_refresh, requestKindFor(false, false)); | |
| 1039 | try testing.expectEqual(RequestKind.processing, requestKindFor(true, false)); | |
| 1040 | try testing.expectEqual(RequestKind.processing, requestKindFor(false, true)); | |
| 1041 | try testing.expectEqual(RequestKind.processing, requestKindFor(true, true)); | |
| 1042 | ||
| 1043 | // The reference page's Schedule button sends `requiresNetwork: true`, so | |
| 1044 | // it is the processing path that has to keep working. | |
| 1045 | var parsed = try parse( | |
| 1046 | "{\"taskId\":\"craft-sync\",\"delay\":900,\"requiresNetwork\":true,\"requiresCharging\":false}", | |
| 1047 | ); | |
| 1048 | defer parsed.deinit(); | |
| 1049 | const request = try parseScheduleRequest(parsed.value); | |
| 1050 | try testing.expectEqual( | |
| 1051 | RequestKind.processing, | |
| 1052 | requestKindFor(request.requires_network, request.requires_charging), | |
| 1053 | ); | |
| 1054 | } | |
| 1055 | ||
| 1056 | test "the identifier the reply names is the bundle-prefixed one" { | |
| 1057 | // `"\(bundleIdentifier).\(taskId)"`. The prefixed id is what comes back to | |
| 1058 | // the page, and `test-bridges.html` prints it — so this string is contract, | |
| 1059 | // not an implementation detail. | |
| 1060 | const joined = try joinIdentifier(testing.allocator, "com.acme.app", "craft-sync"); | |
| 1061 | defer testing.allocator.free(joined); | |
| 1062 | try testing.expectEqualStrings("com.acme.app.craft-sync", joined); | |
| 1063 | ||
| 1064 | // The shim's nil-bundle spelling, kept byte-identical so the parity trade | |
| 1065 | // described in the module header stays one line away. | |
| 1066 | const fallback = try joinIdentifier(testing.allocator, default_bundle_prefix, "craft-sync"); | |
| 1067 | defer testing.allocator.free(fallback); | |
| 1068 | try testing.expectEqualStrings("com.craft.app.craft-sync", fallback); | |
| 1069 | ||
| 1070 | // Swift's interpolation puts the dot in unconditionally, so an empty | |
| 1071 | // taskId yields a trailing dot rather than the bare bundle id. | |
| 1072 | const empty = try joinIdentifier(testing.allocator, "com.acme.app", ""); | |
| 1073 | defer testing.allocator.free(empty); | |
| 1074 | try testing.expectEqualStrings("com.acme.app.", empty); | |
| 1075 | } | |
| 1076 | ||
| 1077 | test "the schedule reply is the object Swift resolves, not a bare fragment" { | |
| 1078 | // `resolveCallback(callbackId, result: ["taskId": fullTaskId, "scheduled": | |
| 1079 | // true])`. `test-bridges.html` does `JSON.stringify(result)`, so both keys | |
| 1080 | // are observable and a bare `true` would change what the page sees. | |
| 1081 | const reply = try taskReply(testing.allocator, "com.acme.app.craft-sync", scheduled_key); | |
| 1082 | defer testing.allocator.free(reply); | |
| 1083 | try testing.expectEqualStrings( | |
| 1084 | "{\"taskId\":\"com.acme.app.craft-sync\",\"scheduled\":true}", | |
| 1085 | reply, | |
| 1086 | ); | |
| 1087 | ||
| 1088 | var parsed = try parse(reply); | |
| 1089 | defer parsed.deinit(); | |
| 1090 | try testing.expectEqualStrings("com.acme.app.craft-sync", parsed.value.object.get("taskId").?.string); | |
| 1091 | try testing.expect(parsed.value.object.get("scheduled").?.bool); | |
| 1092 | } | |
| 1093 | ||
| 1094 | test "the cancel reply carries taskId and cancelled" { | |
| 1095 | const reply = try taskReply(testing.allocator, "com.acme.app.craft-sync", cancelled_key); | |
| 1096 | defer testing.allocator.free(reply); | |
| 1097 | try testing.expectEqualStrings( | |
| 1098 | "{\"taskId\":\"com.acme.app.craft-sync\",\"cancelled\":true}", | |
| 1099 | reply, | |
| 1100 | ); | |
| 1101 | } | |
| 1102 | ||
| 1103 | test "the cancel-all reply has no taskId key at all" { | |
| 1104 | // The one reply of the three that differs in shape: Swift resolves | |
| 1105 | // `["cancelled": true]` with nothing else in it. A page reading | |
| 1106 | // `result.taskId` there must get undefined, as it does today. | |
| 1107 | try testing.expectEqualStrings("{\"cancelled\":true}", cancel_all_fragment); | |
| 1108 | try testing.expect(std.mem.indexOf(u8, cancel_all_fragment, "taskId") == null); | |
| 1109 | // The same key spelling the id-carrying cancel uses. | |
| 1110 | try testing.expect(std.mem.indexOf(u8, cancel_all_fragment, cancelled_key) != null); | |
| 1111 | ||
| 1112 | var parsed = try parse(cancel_all_fragment); | |
| 1113 | defer parsed.deinit(); | |
| 1114 | try testing.expect(parsed.value.object.get("taskId") == null); | |
| 1115 | try testing.expect(parsed.value.object.get("cancelled").?.bool); | |
| 1116 | } | |
| 1117 | ||
| 1118 | test "a taskId that would break the reply is escaped, not injected" { | |
| 1119 | // The whole reply is replayed into `evaluateJavaScript:` as source, and | |
| 1120 | // the identifier embeds a page-controlled name. An unescaped quote would | |
| 1121 | // terminate the JSON string and break the page's promise resolution — or | |
| 1122 | // worse. | |
| 1123 | const full = try joinIdentifier(testing.allocator, "com.acme.app", "a\"b\\c\nd"); | |
| 1124 | defer testing.allocator.free(full); | |
| 1125 | ||
| 1126 | const reply = try taskReply(testing.allocator, full, cancelled_key); | |
| 1127 | defer testing.allocator.free(reply); | |
| 1128 | ||
| 1129 | var parsed = try parse(reply); | |
| 1130 | defer parsed.deinit(); | |
| 1131 | // Round-trips to exactly the identifier that was addressed, so the page is | |
| 1132 | // told the truth about which task it cancelled. | |
| 1133 | try testing.expectEqualStrings(full, parsed.value.object.get("taskId").?.string); | |
| 1134 | } | |
| 1135 | ||
| 1136 | test "off Darwin the handlers refuse rather than fake success" { | |
| 1137 | if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 1138 | ||
| 1139 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 1140 | defer bridge.deinit(); | |
| 1141 | ||
| 1142 | // Valid payloads, so the refusal is the platform's and not the parser's. | |
| 1143 | try testing.expectError( | |
| 1144 | error.UnsupportedPlatform, | |
| 1145 | bridge.handleMessage( | |
| 1146 | A.schedule_background_task, | |
| 1147 | "{\"taskId\":\"craft-sync\",\"delay\":900,\"requiresNetwork\":true,\"requiresCharging\":false}", | |
| 1148 | ), | |
| 1149 | ); | |
| 1150 | try testing.expectError( | |
| 1151 | error.UnsupportedPlatform, | |
| 1152 | bridge.handleMessage(A.cancel_background_task, "{\"taskId\":\"craft-sync\"}"), | |
| 1153 | ); | |
| 1154 | try testing.expectError( | |
| 1155 | error.UnsupportedPlatform, | |
| 1156 | bridge.handleMessage(A.cancel_all_background_tasks, "{}"), | |
| 1157 | ); | |
| 1158 | } | |
| 1159 | ||
| 1160 | test "without a bundle identifier the guard fires before the scheduler is touched" { | |
| 1161 | if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 1162 | ||
| 1163 | // The host test runner is a bare binary with no Info.plist. The guard must | |
| 1164 | // turn that into an error reply for all three actions, including the one | |
| 1165 | // that needs no prefix — cancel-all still reaches the scheduler. | |
| 1166 | if (requireBundlePrefix()) |_| { | |
| 1167 | // A runner that does carry a bundle identifier (tests hosted inside an | |
| 1168 | // app) would make the calls below real cancellations against that | |
| 1169 | // app's pending work; proving the guard needs the bundle-less | |
| 1170 | // environment, so skip rather than pretend. | |
| 1171 | return error.SkipZigTest; | |
| 1172 | } else |err| switch (err) { | |
| 1173 | error.NoBundleIdentifier => { | |
| 1174 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 1175 | defer bridge.deinit(); | |
| 1176 | ||
| 1177 | try testing.expectError( | |
| 1178 | error.NoBundleIdentifier, | |
| 1179 | bridge.handleMessage(A.schedule_background_task, "{\"taskId\":\"conformance-probe\"}"), | |
| 1180 | ); | |
| 1181 | try testing.expectError( | |
| 1182 | error.NoBundleIdentifier, | |
| 1183 | bridge.handleMessage(A.cancel_background_task, "{\"taskId\":\"conformance-probe\"}"), | |
| 1184 | ); | |
| 1185 | try testing.expectError( | |
| 1186 | error.NoBundleIdentifier, | |
| 1187 | bridge.handleMessage(A.cancel_all_background_tasks, "{}"), | |
| 1188 | ); | |
| 1189 | }, | |
| 1190 | else => return err, | |
| 1191 | } | |
| 1192 | } | |
| 1193 | ||
| 1194 | test "BackgroundTasks is absent from the host binary, so the scheduler is a named refusal" { | |
| 1195 | if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest; | |
| 1196 | ||
| 1197 | if (objc.objc_getClass("BGTaskScheduler") != null) { | |
| 1198 | // Something now links BackgroundTasks into the iOS test artifacts. | |
| 1199 | // Calling `sharedScheduler` from here would run it in a bundle-less | |
| 1200 | // process, which is the exact unknown this module refuses to explore, | |
| 1201 | // so skip loudly rather than risk an uncatchable ObjC exception. | |
| 1202 | std.debug.print( | |
| 1203 | "BGTaskScheduler is loaded in the test process; the class-not-found path is no longer reachable here\n", | |
| 1204 | .{}, | |
| 1205 | ); | |
| 1206 | return error.SkipZigTest; | |
| 1207 | } | |
| 1208 | ||
| 1209 | // The normal answer: no framework, so a named error instead of a crash or | |
| 1210 | // a fabricated success. This is also the answer a shipped app would start | |
| 1211 | // giving if `import BackgroundTasks` were removed from CraftApp.swift, | |
| 1212 | // since frameworks are linked at the app level in Xcode. | |
| 1213 | try testing.expectError(error.ClassNotFound, scheduler()); | |
| 1214 | } | |
| 1215 | ||
| 1216 | test "validation outruns Objective-C on every platform" { | |
| 1217 | // The same refusals with and without a runtime behind them: a payload the | |
| 1218 | // handler must refuse is refused identically on Linux and macOS, which is | |
| 1219 | // what makes the parser tests above binding for the device build too. | |
| 1220 | var bridge = BgTasksBridge.init(testing.allocator); | |
| 1221 | defer bridge.deinit(); | |
| 1222 | ||
| 1223 | try testing.expectError( | |
| 1224 | BridgeError.MissingData, | |
| 1225 | bridge.handleMessage(A.schedule_background_task, "{}"), | |
| 1226 | ); | |
| 1227 | try testing.expectError( | |
| 1228 | BridgeError.InvalidParameter, | |
| 1229 | bridge.handleMessage(A.cancel_background_task, "{\"taskId\":42}"), | |
| 1230 | ); | |
| 1231 | try testing.expectError( | |
| 1232 | BridgeError.InvalidParameter, | |
| 1233 | bridge.handleMessage(A.schedule_background_task, "{\"taskId\":\"a\\u0000b\"}"), | |
| 1234 | ); | |
| 1235 | try testing.expectError( | |
| 1236 | BridgeError.InvalidParameter, | |
| 1237 | bridge.handleMessage(A.schedule_background_task, "{\"taskId\":\"t\",\"delay\":\"soon\"}"), | |
| 1238 | ); | |
| 1239 | try testing.expectError( | |
| 1240 | BridgeError.InvalidParameter, | |
| 1241 | bridge.handleMessage(A.schedule_background_task, "{\"taskId\":\"t\",\"requiresCharging\":\"yes\"}"), | |
| 1242 | ); | |
| 1243 | } | |
packages/zig/src/bridge_mobile_notifications.zigadded+937-0
| @@ -0,0 +1,937 @@ | ||
| 1 | //! The local-notification *read* action of the `mobile` namespace: | |
| 2 | //! `getPendingNotifications`. | |
| 3 | //! | |
| 4 | //! One JS surface reaches it: the legacy `craft.notifications.getPending()`, | |
| 5 | //! which posts `{action:'getPendingNotifications', callbackId: id}` and hands | |
| 6 | //! back a promise built by hand rather than through `_createCallback` — so it | |
| 7 | //! has **no timeout**. A dropped reply parks the page forever, which is why | |
| 8 | //! every path below ends in a reply, an error, or a loudly logged refusal. | |
| 9 | //! | |
| 10 | //! ## What is carried across exactly, because it is the observable contract | |
| 11 | //! | |
| 12 | //! - **No payload.** The injected JS posts the action alone and the Swift | |
| 13 | //! dispatcher reads nothing out of `body`. `ios_dispatch.payloadOf` hands | |
| 14 | //! this handler `"{}"` for an absent `d`; it is ignored rather than parsed, | |
| 15 | //! exactly as `cancelAllNotifications` ignores its own. A page that sends | |
| 16 | //! junk in `d` still gets the pending list, because that is what Swift does. | |
| 17 | //! - **The reply is a bare JSON array**, not a wrapper object. Swift builds | |
| 18 | //! `[[String: Any]]` and resolves it through `JSONSerialization` with | |
| 19 | //! `.fragmentsAllowed`, so the resolved value is | |
| 20 | //! `[{"id":…,"title":…,"body":…,"subtitle":…}, …]`, and `[]` when nothing | |
| 21 | //! is scheduled. `test-bridges.html` does `JSON.stringify(pending, null, 2)` | |
| 22 | //! straight onto the resolved value; any wrapper would change what it prints. | |
| 23 | //! - **Four keys per entry, `subtitle` included.** `craft.d.ts`'s | |
| 24 | //! `PendingNotification` is `{id, title, body}` and omits `subtitle`, but | |
| 25 | //! Swift emits it and Swift is the contract the migration has to preserve; | |
| 26 | //! the `.d.ts` is behind. All four are non-nullable `NSString`s that default | |
| 27 | //! to `""`, so every entry always carries every key. Swift's dictionary | |
| 28 | //! ordering is arbitrary — this module fixes the order at id, title, body, | |
| 29 | //! subtitle so the bytes on the wire are testable. | |
| 30 | //! - **The trigger is not in the reply.** Swift never reads | |
| 31 | //! `request.trigger`, so there is no "when does it fire" field to match and | |
| 32 | //! none is invented here. | |
| 33 | //! - **No authorization prompt.** Swift's read path calls | |
| 34 | //! `getPendingNotificationRequests` directly; asking for permission on a | |
| 35 | //! read would put a system alert in front of a user who only listed | |
| 36 | //! something. | |
| 37 | //! | |
| 38 | //! ## What is deliberately not carried across | |
| 39 | //! | |
| 40 | //! **Swift's silent hang.** The dispatcher arm is | |
| 41 | //! `if config.enableLocalNotifications { … }` with no `else`, and the gate | |
| 42 | //! defaults to `false` — so with notifications disabled the untimed promise | |
| 43 | //! never settles. `enableLocalNotifications` has no mirror anywhere in | |
| 44 | //! `packages/zig/src`, so this action is served unconditionally, the same call | |
| 45 | //! `bridge_mobile_securestore.zig` and `bridge_mobile_notifcancel.zig` made. | |
| 46 | //! Listing an app's *own* pending notifications grants a page strictly less | |
| 47 | //! than the cancel-everything that precedent already serves ungated. | |
| 48 | //! | |
| 49 | //! **A truncated string reported as the whole one.** `-[NSString UTF8String]` | |
| 50 | //! is NUL-terminated, so a stored title carrying an embedded U+0000 — which | |
| 51 | //! Swift's `JSONSerialization` would round-trip intact — reads back short. | |
| 52 | //! Unreachable from craft's own scheduler, reachable from an app that | |
| 53 | //! schedules notifications in native code. Rather than report the truncation | |
| 54 | //! as the full title, the byte length is checked against | |
| 55 | //! `lengthOfBytesUsingEncoding:` and a mismatch fails the whole reply. | |
| 56 | //! | |
| 57 | //! ## The bundle-identifier guard, first and non-negotiable | |
| 58 | //! | |
| 59 | //! `[UNUserNotificationCenter currentNotificationCenter]` raises | |
| 60 | //! `NSInternalInconsistencyException` in a process with no bundle identifier, | |
| 61 | //! and an uncaught Objective-C exception is an uncatchable SIGABRT rather than | |
| 62 | //! an error to map. That is exactly the process the bare `craft` fixture binary | |
| 63 | //! and this file's own test runner are, so `requireBundleIdentifier` runs | |
| 64 | //! before anything touches the center — the same guard | |
| 65 | //! `bridge_mobile_notifcancel.zig` documents. | |
| 66 | //! | |
| 67 | //! ## `scheduleNotification` is deliberately absent from `A` | |
| 68 | //! | |
| 69 | //! It is not declared `.unavailable` — it is not claimed at all, so | |
| 70 | //! `ios_dispatch` falls through to the Swift shim, which schedules | |
| 71 | //! notifications correctly today whenever the gate is on. `.unavailable` would | |
| 72 | //! *steal* a working action in order to refuse it, which is strictly worse for | |
| 73 | //! the page than leaving it where it works. | |
| 74 | //! | |
| 75 | //! The reason it cannot be served honestly yet is a missing piece of | |
| 76 | //! `ios_async`, not a missing selector: | |
| 77 | //! | |
| 78 | //! 1. **Both Swift failure paths are rejections**, not results: | |
| 79 | //! `rejectCallback(callbackId, error: "Permission denied")` when | |
| 80 | //! authorization is refused, and `rejectCallback(callbackId, error: | |
| 81 | //! error.localizedDescription)` when `addNotificationRequest:` fails. | |
| 82 | //! `ios_async` can only *resolve* — `deliverJson` is the whole asynchronous | |
| 83 | //! reply surface. Resolving the string "denied" for a denied prompt would | |
| 84 | //! be textbook fabricated success: the page's `catch` never runs and its | |
| 85 | //! `lastNotificationId` becomes the word "denied", which it then feeds | |
| 86 | //! straight into `cancelNotification`. `craft_ios_deliver_error` is not the | |
| 87 | //! way out; it is an export for the Swift shim and calls `evalJS` inline, | |
| 88 | //! so calling it from a UserNotifications completion queue would run | |
| 89 | //! `evaluateJavaScript:` off the main thread. The fix is an | |
| 90 | //! `ios_async.deliverError(ticket, code, message)` sharing `deliverJson`'s | |
| 91 | //! slot, `dispatch_async_f` hop, and request-id restoration; hand-rolling a | |
| 92 | //! second main-queue hop in a bridge module is the bug that file exists to | |
| 93 | //! prevent. | |
| 94 | //! 2. **It is a two-stage chain over an autoreleased object.** | |
| 95 | //! `requestWithIdentifier:content:trigger:` is autoreleased, and the pool | |
| 96 | //! it lives in drains long before the authorization completion fires, so | |
| 97 | //! the request has to be retained and stashed per slot alongside a | |
| 98 | //! pre-escaped reply fragment for the identifier — the same side table this | |
| 99 | //! file already keeps for the read path, one field wider. | |
| 100 | //! 3. Two decisions would have to be recorded rather than inherited: Swift | |
| 101 | //! passes `delay / 1000` to `UNTimeIntervalNotificationTrigger` | |
| 102 | //! unguarded, and an interval of zero or less raises an uncatchable ObjC | |
| 103 | //! exception, so `{delay: 0}` SIGABRTs the app today | |
| 104 | //! (`bridge_notification.zig` documents the same trap and answers it with | |
| 105 | //! a nil trigger, which is how UserNotifications spells "now"); and the | |
| 106 | //! page's fields arrive nested under `notification`, with `scheduleAt`, | |
| 107 | //! `sound`, and `data` present in the TypeScript surface and dropped on the | |
| 108 | //! floor by Swift. | |
| 109 | //! | |
| 110 | //! None of that is guesswork — it is a second file's worth of change to | |
| 111 | //! `ios_async`, which this pass is not making. Until it is made, the shim keeps | |
| 112 | //! the action. | |
| 113 | ||
| 114 | const std = ; | |
| 115 | const builtin = ; | |
| 116 | const capabilities = ; | |
| 117 | const bridge_error = ; | |
| 118 | const objc_runtime = ; | |
| 119 | const ios_async = ; | |
| 120 | const compat_mutex = ; | |
| 121 | ||
| 122 | const objc = objc_runtime.objc; | |
| 123 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 124 | ||
| 125 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the | |
| 126 | /// notifcancel/securestore precedent: `objc_runtime.objc` is an empty struct | |
| 127 | /// off Darwin and a signature is analysed even when a comptime guard prunes the | |
| 128 | /// body, so naming `objc.id` here would break the Linux build. A single | |
| 129 | /// optional pointer, never `?objc.id` — a double optional is illegal in | |
| 130 | /// `callconv(.c)`. | |
| 131 | const Id = ?*anyopaque; | |
| 132 | ||
| 133 | /// The action names, spelled exactly as the Swift `case` labels spell them. | |
| 134 | /// `test/ios_conformance_test.zig` matches this block against those labels in | |
| 135 | /// both directions. | |
| 136 | /// | |
| 137 | /// What it does *not* do is check that two modules never claim the same | |
| 138 | /// action, though `ios_dispatch` says it does: `collectZigActions` folds every | |
| 139 | /// module's block into one `StringHashMap`, so a duplicate is invisible there | |
| 140 | /// and `ios_dispatch`'s first-match routing would quietly become | |
| 141 | /// order-dependent. Until that check exists the tests below stand in for it, | |
| 142 | /// pinning the near neighbours — `cancelNotification`, | |
| 143 | /// `cancelAllNotifications`, `scheduleNotification` — as actions this module | |
| 144 | /// does not answer. | |
| 145 | /// | |
| 146 | /// Only the read action is here. See the module comment for why | |
| 147 | /// scheduleNotification is left to the shim rather than claimed and refused. | |
| 148 | pub const A = struct { | |
| 149 | pub const get_pending_notifications = "getPendingNotifications"; | |
| 150 | }; | |
| 151 | ||
| 152 | /// `.result`: Swift resolves the callback with the array, and the JS promise is | |
| 153 | /// the untimed legacy kind — `.none` would strand a caller forever rather than | |
| 154 | /// for thirty seconds. | |
| 155 | /// | |
| 156 | /// `.live`: the read needs no permission prompt, no entitlement, and no | |
| 157 | /// configuration beyond a bundle identifier. Nothing here warrants | |
| 158 | /// `.unavailable`. | |
| 159 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 160 | .{ .name = A.get_pending_notifications, .reply = .result }, | |
| 161 | }; | |
| 162 | ||
| 163 | /// Which handler an action selects, split out from `handleMessage` so the | |
| 164 | /// table-versus-dispatch agreement is assertable on a host without issuing the | |
| 165 | /// Objective-C call the handler exists to make. | |
| 166 | const Route = enum { get_pending }; | |
| 167 | ||
| 168 | fn routeFor(action: []const u8) ?Route { | |
| 169 | if (std.mem.eql(u8, action, A.get_pending_notifications)) return .get_pending; | |
| 170 | return null; | |
| 171 | } | |
| 172 | ||
| 173 | pub const NotificationsBridge = struct { | |
| 174 | allocator: std.mem.Allocator, | |
| 175 | ||
| 176 | const Self = @This(); | |
| 177 | ||
| 178 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 179 | return .{ .allocator = allocator }; | |
| 180 | } | |
| 181 | ||
| 182 | pub fn deinit(_: *Self) void {} | |
| 183 | ||
| 184 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 185 | const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; | |
| 186 | // Exhaustive, so a `Route` without a handler is a compile error. | |
| 187 | return switch (route) { | |
| 188 | .get_pending => self.getPendingNotifications(data), | |
| 189 | }; | |
| 190 | } | |
| 191 | ||
| 192 | /// List the app's pending notification requests. | |
| 193 | /// | |
| 194 | /// `data` is accepted and ignored: the injected JS posts no payload and the | |
| 195 | /// Swift dispatcher reads none, so parsing one here would invent a way for | |
| 196 | /// this call to fail that the shim does not have. | |
| 197 | /// | |
| 198 | /// Every fallible step — the bundle guard, the center, all nine selectors — | |
| 199 | /// runs *before* `ios_async.acquire`, so there is no error path between | |
| 200 | /// leasing a slot and handing the block to the framework. That ordering is | |
| 201 | /// the whole reason this reads as one straight line: a failure after the | |
| 202 | /// lease would have to release the slot by hand, and a missed release is a | |
| 203 | /// permanently narrower pool. | |
| 204 | fn getPendingNotifications(self: *Self, data: []const u8) !void { | |
| 205 | _ = self; | |
| 206 | _ = data; | |
| 207 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 208 | ||
| 209 | try requireBundleIdentifier(); | |
| 210 | const center = try notificationCenter(); | |
| 211 | const sel_get = objc.sel_registerName("getPendingNotificationRequestsWithCompletionHandler:") orelse | |
| 212 | return error.SelectorNotFound; | |
| 213 | const sels = try Sels.resolve(); | |
| 214 | ||
| 215 | const ticket = ios_async.acquire(A.get_pending_notifications) orelse return poolFull(); | |
| 216 | ||
| 217 | // Published before the framework call, never after: the completion runs | |
| 218 | // on a queue UserNotifications picks and can fire before `msgSend` | |
| 219 | // returns. A block that arrived at an empty side-table slot would have | |
| 220 | // no ticket to reply with. | |
| 221 | publishPendingCall(ticket, sels); | |
| 222 | ||
| 223 | // The completion is `void (^)(NSArray<UNNotificationRequest *> *)`, | |
| 224 | // which neither `boolBlock` nor `boolErrorBlock` fits — hence this | |
| 225 | // module's own per-slot global block, feeding `ios_async.deliverJson`. | |
| 226 | const Fn = *const fn (Id, Id, *anyopaque) callconv(.c) void; | |
| 227 | const func: Fn = @ptrCast(&objc.objc_msgSend); | |
| 228 | func(center, sel_get, arrayBlock(ticket)); | |
| 229 | } | |
| 230 | }; | |
| 231 | ||
| 232 | /// The answer for a full block pool, copied from `bridge_mobile_permissions`: | |
| 233 | /// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes' | |
| 234 | /// designated stand-in, and the point is that the seventeenth concurrent call | |
| 235 | /// gets an explicit rejection instead of a promise that never settles. | |
| 236 | fn poolFull() bridge_error.BridgeError { | |
| 237 | std.log.warn( | |
| 238 | "getPendingNotifications refused: all {d} async slots in flight", | |
| 239 | .{ios_async.max_in_flight}, | |
| 240 | ); | |
| 241 | return bridge_error.BridgeError.InvalidParameter; | |
| 242 | } | |
| 243 | ||
| 244 | // ============================================================================= | |
| 245 | // Reply shaping. Pure — no Objective-C — so the exact bytes the page receives | |
| 246 | // are pinned by host tests on every platform. | |
| 247 | // ============================================================================= | |
| 248 | ||
| 249 | /// The array Swift's `requests.map { … }` produces, assembled incrementally so | |
| 250 | /// the Objective-C walk can feed it one request at a time without first | |
| 251 | /// materialising every string. | |
| 252 | /// | |
| 253 | /// Key order is fixed at id, title, body, subtitle. Swift's is a `Dictionary` | |
| 254 | /// and therefore arbitrary; a caller cannot depend on it, but a *test* can only | |
| 255 | /// pin bytes that are deterministic, so one order is chosen and held. | |
| 256 | const PendingList = struct { | |
| 257 | allocator: std.mem.Allocator, | |
| 258 | out: std.ArrayListUnmanaged(u8) = .empty, | |
| 259 | written: usize = 0, | |
| 260 | ||
| 261 | fn init(allocator: std.mem.Allocator) PendingList { | |
| 262 | return .{ .allocator = allocator }; | |
| 263 | } | |
| 264 | ||
| 265 | fn deinit(self: *PendingList) void { | |
| 266 | self.out.deinit(self.allocator); | |
| 267 | } | |
| 268 | ||
| 269 | fn append( | |
| 270 | self: *PendingList, | |
| 271 | notif_id: []const u8, | |
| 272 | title: []const u8, | |
| 273 | body: []const u8, | |
| 274 | subtitle: []const u8, | |
| 275 | ) !void { | |
| 276 | try self.out.append(self.allocator, if (self.written == 0) '[' else ','); | |
| 277 | try self.out.appendSlice(self.allocator, "{\"id\":"); | |
| 278 | try self.appendString(notif_id); | |
| 279 | try self.out.appendSlice(self.allocator, ",\"title\":"); | |
| 280 | try self.appendString(title); | |
| 281 | try self.out.appendSlice(self.allocator, ",\"body\":"); | |
| 282 | try self.appendString(body); | |
| 283 | try self.out.appendSlice(self.allocator, ",\"subtitle\":"); | |
| 284 | try self.appendString(subtitle); | |
| 285 | try self.out.append(self.allocator, '}'); | |
| 286 | self.written += 1; | |
| 287 | } | |
| 288 | ||
| 289 | /// Every one of these four values is app-controlled text that ends up | |
| 290 | /// replayed into the source `evaluateJavaScript:` parses, so it goes | |
| 291 | /// through the shared escaper rather than `{s}` — a `"` in a notification | |
| 292 | /// title would otherwise break the page's promise resolution. | |
| 293 | fn appendString(self: *PendingList, s: []const u8) !void { | |
| 294 | try self.out.append(self.allocator, '"'); | |
| 295 | try bridge_error.appendJsonEscaped(self.allocator, &self.out, s); | |
| 296 | try self.out.append(self.allocator, '"'); | |
| 297 | } | |
| 298 | ||
| 299 | /// The finished fragment. An empty queue is `[]` — a real answer that | |
| 300 | /// resolves, not an error and not `null`. | |
| 301 | fn finish(self: *PendingList) ![]u8 { | |
| 302 | if (self.written == 0) try self.out.append(self.allocator, '['); | |
| 303 | try self.out.append(self.allocator, ']'); | |
| 304 | return self.out.toOwnedSlice(self.allocator); | |
| 305 | } | |
| 306 | }; | |
| 307 | ||
| 308 | // ============================================================================= | |
| 309 | // Objective-C: selectors resolved up front, then a walk that cannot look | |
| 310 | // anything up. | |
| 311 | // ============================================================================= | |
| 312 | ||
| 313 | /// `NSUTF8StringEncoding`. Used only to ask a string how long it really is, so | |
| 314 | /// a NUL-truncated read can be told from a short string. | |
| 315 | const ns_utf8_string_encoding: c_ulong = 4; | |
| 316 | ||
| 317 | fn selector(name: [*:0]const u8) !Id { | |
| 318 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 319 | return objc.sel_registerName(name) orelse error.SelectorNotFound; | |
| 320 | } | |
| 321 | ||
| 322 | /// Every selector the completion block needs, resolved while a synchronous | |
| 323 | /// error is still deliverable. | |
| 324 | /// | |
| 325 | /// The block runs after the dispatch frame is gone; a `sel_registerName` | |
| 326 | /// failure in there could only be logged and dropped, because `ios_async` has | |
| 327 | /// no error channel. Resolving them here turns that class of failure into an | |
| 328 | /// ordinary rejection the page can see. | |
| 329 | const Sels = struct { | |
| 330 | count: Id, | |
| 331 | object_at: Id, | |
| 332 | identifier: Id, | |
| 333 | content: Id, | |
| 334 | title: Id, | |
| 335 | body: Id, | |
| 336 | subtitle: Id, | |
| 337 | utf8: Id, | |
| 338 | utf8_length: Id, | |
| 339 | ||
| 340 | fn resolve() !Sels { | |
| 341 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 342 | return .{ | |
| 343 | .count = try selector("count"), | |
| 344 | .object_at = try selector("objectAtIndex:"), | |
| 345 | .identifier = try selector("identifier"), | |
| 346 | .content = try selector("content"), | |
| 347 | .title = try selector("title"), | |
| 348 | .body = try selector("body"), | |
| 349 | .subtitle = try selector("subtitle"), | |
| 350 | .utf8 = try selector("UTF8String"), | |
| 351 | .utf8_length = try selector("lengthOfBytesUsingEncoding:"), | |
| 352 | }; | |
| 353 | } | |
| 354 | }; | |
| 355 | ||
| 356 | /// Refuse to go anywhere near `UNUserNotificationCenter` in a process without a | |
| 357 | /// bundle identifier: `currentNotificationCenter` raises | |
| 358 | /// `NSInternalInconsistencyException` there, and an uncaught Objective-C | |
| 359 | /// exception is an uncatchable SIGABRT, not an error to map. | |
| 360 | fn requireBundleIdentifier() !void { | |
| 361 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 362 | ||
| 363 | const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; | |
| 364 | const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; | |
| 365 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoBundleIdentifier; | |
| 366 | const sel_ident = objc.sel_registerName("bundleIdentifier") orelse return error.SelectorNotFound; | |
| 367 | if (objc.msgSendId(bundle, sel_ident) == null) return error.NoBundleIdentifier; | |
| 368 | } | |
| 369 | ||
| 370 | /// `[UNUserNotificationCenter currentNotificationCenter]`, guarded. A null | |
| 371 | /// class means UserNotifications.framework is not in the process — a link | |
| 372 | /// configuration problem, named rather than crashed on. | |
| 373 | fn notificationCenter() !Id { | |
| 374 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 375 | ||
| 376 | const UNUserNotificationCenter = objc.objc_getClass("UNUserNotificationCenter") orelse | |
| 377 | return error.ClassNotFound; | |
| 378 | const sel_current = objc.sel_registerName("currentNotificationCenter") orelse | |
| 379 | return error.SelectorNotFound; | |
| 380 | return objc.msgSendId(UNUserNotificationCenter, sel_current) orelse error.NativeCallFailed; | |
| 381 | } | |
| 382 | ||
| 383 | /// One `NSString` as bytes, or a refusal. | |
| 384 | /// | |
| 385 | /// The truncation check is the reason this is not one line. `UTF8String` hands | |
| 386 | /// back a NUL-terminated buffer, so a title containing U+0000 — legal in a | |
| 387 | /// notification, and preserved by the `JSONSerialization` this reply is | |
| 388 | /// bug-compatible with — reads back as its prefix. | |
| 389 | /// `lengthOfBytesUsingEncoding:` counts the real encoded bytes, so a mismatch | |
| 390 | /// is exactly that case, and reporting the prefix as the whole title is the | |
| 391 | /// one thing worse than failing. | |
| 392 | /// | |
| 393 | /// The returned slice borrows the string's internal buffer, which is valid for | |
| 394 | /// the current autorelease pool; every caller copies it into the reply before | |
| 395 | /// returning. | |
| 396 | fn readString(ns: Id, sels: Sels) ![]const u8 { | |
| 397 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 398 | if (ns == null) return error.NativeCallFailed; | |
| 399 | ||
| 400 | const Utf8Fn = *const fn (Id, Id) callconv(.c) ?[*:0]const u8; | |
| 401 | const utf8: Utf8Fn = @ptrCast(&objc.objc_msgSend); | |
| 402 | const cstr = utf8(ns, sels.utf8) orelse return error.NativeCallFailed; | |
| 403 | const text = std.mem.span(cstr); | |
| 404 | ||
| 405 | const LenFn = *const fn (Id, Id, c_ulong) callconv(.c) c_ulong; | |
| 406 | const len_of: LenFn = @ptrCast(&objc.objc_msgSend); | |
| 407 | const encoded = len_of(ns, sels.utf8_length, ns_utf8_string_encoding); | |
| 408 | if (encoded != text.len) return error.EmbeddedNulInNativeString; | |
| 409 | ||
| 410 | return text; | |
| 411 | } | |
| 412 | ||
| 413 | /// Walk `NSArray<UNNotificationRequest *>` into the reply fragment. | |
| 414 | /// | |
| 415 | /// A nil array is refused rather than answered `[]`: the completion parameter | |
| 416 | /// is declared non-nullable, so nil would mean the framework broke its own | |
| 417 | /// contract, and "no pending notifications" is a claim this module would have | |
| 418 | /// no basis for. | |
| 419 | fn shapePendingReply(allocator: std.mem.Allocator, requests: Id, sels: Sels) ![]u8 { | |
| 420 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 421 | if (requests == null) return error.NativeCallFailed; | |
| 422 | ||
| 423 | const CountFn = *const fn (Id, Id) callconv(.c) c_ulong; | |
| 424 | const count_of: CountFn = @ptrCast(&objc.objc_msgSend); | |
| 425 | const total = count_of(requests, sels.count); | |
| 426 | ||
| 427 | var list = PendingList.init(allocator); | |
| 428 | defer list.deinit(); | |
| 429 | ||
| 430 | var i: c_ulong = 0; | |
| 431 | while (i < total) : (i += 1) { | |
| 432 | const request = objc.msgSendId1(requests, sels.object_at, i) orelse return error.NativeCallFailed; | |
| 433 | const content = objc.msgSendId(request, sels.content) orelse return error.NativeCallFailed; | |
| 434 | ||
| 435 | try list.append( | |
| 436 | try readString(objc.msgSendId(request, sels.identifier), sels), | |
| 437 | try readString(objc.msgSendId(content, sels.title), sels), | |
| 438 | try readString(objc.msgSendId(content, sels.body), sels), | |
| 439 | try readString(objc.msgSendId(content, sels.subtitle), sels), | |
| 440 | ); | |
| 441 | } | |
| 442 | ||
| 443 | return list.finish(); | |
| 444 | } | |
| 445 | ||
| 446 | // ============================================================================= | |
| 447 | // The per-slot completion block, and the side table that gives it a ticket. | |
| 448 | // | |
| 449 | // A global block captures nothing, which is what makes `Block_copy` on it the | |
| 450 | // identity function and removes every lifetime question. The price is that the | |
| 451 | // block knows only its own slot index, baked in at comptime — so the ticket's | |
| 452 | // generation, and the selectors resolved at dispatch time, are looked up here. | |
| 453 | // ============================================================================= | |
| 454 | ||
| 455 | const PendingCall = struct { | |
| 456 | ticket: ios_async.Ticket, | |
| 457 | sels: Sels, | |
| 458 | }; | |
| 459 | ||
| 460 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); | |
| 461 | var pending_mutex: compat_mutex.Mutex = .{}; | |
| 462 | ||
| 463 | /// Record the call a slot's block will answer. The slot is leased exclusively | |
| 464 | /// by this ticket, so the entry is ours to overwrite. | |
| 465 | fn publishPendingCall(ticket: ios_async.Ticket, sels: Sels) void { | |
| 466 | pending_mutex.lock(); | |
| 467 | defer pending_mutex.unlock(); | |
| 468 | pending_calls[ticket.index] = .{ .ticket = ticket, .sels = sels }; | |
| 469 | } | |
| 470 | ||
| 471 | /// Read and clear a slot's entry. Clearing is what makes a second fire of the | |
| 472 | /// same completion a no-op rather than a second reply. | |
| 473 | fn takePendingCall(index: u5) ?PendingCall { | |
| 474 | pending_mutex.lock(); | |
| 475 | defer pending_mutex.unlock(); | |
| 476 | const call = pending_calls[index]; | |
| 477 | pending_calls[index] = null; | |
| 478 | return call; | |
| 479 | } | |
| 480 | ||
| 481 | const BlockDescriptor = extern struct { | |
| 482 | reserved: c_ulong = 0, | |
| 483 | size: c_ulong, | |
| 484 | }; | |
| 485 | ||
| 486 | /// `void (^)(NSArray<UNNotificationRequest *> *)` — one object argument and no | |
| 487 | /// BOOL, which is why `ios_async`'s pre-built blocks do not fit. | |
| 488 | const ArrayBlock = extern struct { | |
| 489 | isa: ?*anyopaque, | |
| 490 | flags: c_int, | |
| 491 | reserved: c_int = 0, | |
| 492 | invoke: *const anyopaque, | |
| 493 | descriptor: *const BlockDescriptor, | |
| 494 | }; | |
| 495 | ||
| 496 | /// 1 << 28. `Block_copy` on a global block returns the same pointer, so a | |
| 497 | /// module-level block can be handed to an API that escapes it with no heap | |
| 498 | /// copy, no copy/dispose helpers, and no descriptor lifetime. | |
| 499 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 500 | ||
| 501 | const array_block_descriptor = BlockDescriptor{ .size = @sizeOf(ArrayBlock) }; | |
| 502 | ||
| 503 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 504 | ||
| 505 | /// One invoke per slot, comptime-generated so each block knows which slot it is | |
| 506 | /// without capturing anything. | |
| 507 | fn makeArrayInvoke(comptime index: u5) *const anyopaque { | |
| 508 | const S = struct { | |
| 509 | fn invoke(_: *const ArrayBlock, requests: Id) callconv(.c) void { | |
| 510 | pendingCompletionFired(index, requests); | |
| 511 | } | |
| 512 | }; | |
| 513 | return @ptrCast(&S.invoke); | |
| 514 | } | |
| 515 | ||
| 516 | fn makeArrayBlocks() [ios_async.max_in_flight]ArrayBlock { | |
| 517 | var out: [ios_async.max_in_flight]ArrayBlock = undefined; | |
| 518 | for (&out, 0..) |*b, i| { | |
| 519 | b.* = .{ | |
| 520 | .isa = &_NSConcreteGlobalBlock, | |
| 521 | .flags = BLOCK_IS_GLOBAL, | |
| 522 | .invoke = makeArrayInvoke(@intCast(i)), | |
| 523 | .descriptor = &array_block_descriptor, | |
| 524 | }; | |
| 525 | } | |
| 526 | return out; | |
| 527 | } | |
| 528 | ||
| 529 | var array_blocks: [ios_async.max_in_flight]ArrayBlock = | |
| 530 | if (is_darwin) makeArrayBlocks() else undefined; | |
| 531 | ||
| 532 | fn arrayBlock(ticket: ios_async.Ticket) *anyopaque { | |
| 533 | return @ptrCast(&array_blocks[ticket.index]); | |
| 534 | } | |
| 535 | ||
| 536 | /// Runs on whatever queue UserNotifications chose. It must not reply from here | |
| 537 | /// — `evaluateJavaScript:` is main-thread-only — so the finished JSON goes to | |
| 538 | /// `ios_async.deliverJson`, which copies it, hops to the main queue, and | |
| 539 | /// answers under the request id captured back at dispatch. | |
| 540 | /// | |
| 541 | /// The one path that cannot answer is a reply that will not shape: out of | |
| 542 | /// memory, a nil array, or a NUL-truncated string. `ios_async` can resolve and | |
| 543 | /// nothing else, so the honest options are a fabricated result or silence, and | |
| 544 | /// silence is the lesser wrong — the slot is released, the failure is logged | |
| 545 | /// with its cause, and the caller's untimed promise is left hanging rather than | |
| 546 | /// resolved with a lie. This is the second reason the module comment asks for | |
| 547 | /// `ios_async.deliverError`. | |
| 548 | fn pendingCompletionFired(index: u5, requests: Id) void { | |
| 549 | if (!is_darwin) return; | |
| 550 | ||
| 551 | const call = takePendingCall(index) orelse { | |
| 552 | std.log.warn( | |
| 553 | "getPendingNotifications completion fired for slot {d} with no call recorded; ignored", | |
| 554 | .{index}, | |
| 555 | ); | |
| 556 | return; | |
| 557 | }; | |
| 558 | ||
| 559 | const allocator = std.heap.c_allocator; | |
| 560 | const json = shapePendingReply(allocator, requests, call.sels) catch |err| { | |
| 561 | std.log.err( | |
| 562 | "getPendingNotifications could not shape its reply ({}); slot released, caller unanswered", | |
| 563 | .{err}, | |
| 564 | ); | |
| 565 | ios_async.abandon(call.ticket); | |
| 566 | return; | |
| 567 | }; | |
| 568 | defer allocator.free(json); | |
| 569 | ||
| 570 | ios_async.deliverJson(call.ticket, json); | |
| 571 | } | |
| 572 | ||
| 573 | // ============================================================================= | |
| 574 | // Tests — host-only. | |
| 575 | // | |
| 576 | // Everything that decides what the page sees is pinned as pure logic: routing, | |
| 577 | // the action name, the reply bytes, the escaping, the empty case. The one | |
| 578 | // Objective-C path a host can reach — the bundle-identifier refusal — is | |
| 579 | // exercised for real, because the test runner is exactly the bundle-less | |
| 580 | // process the guard exists for. The `getPendingNotificationRequests` call | |
| 581 | // itself is never made from here; in a process that *did* have a bundle | |
| 582 | // identifier it would query that app's real notification center. | |
| 583 | // ============================================================================= | |
| 584 | ||
| 585 | const testing = std.testing; | |
| 586 | ||
| 587 | test "the declared actions are the ones the handler serves" { | |
| 588 | try testing.expectEqual(@as(usize, 1), capability_actions.len); | |
| 589 | try testing.expectEqualStrings(A.get_pending_notifications, capability_actions[0].name); | |
| 590 | ||
| 591 | // A `.result` whose handler never replies parks the caller on an untimed | |
| 592 | // promise; a `.none` that is awaited resolves immediately and means | |
| 593 | // nothing. Both failure modes are invisible from the page, so the field is | |
| 594 | // asserted rather than assumed. | |
| 595 | try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply); | |
| 596 | try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[0].status); | |
| 597 | // `.live` and a reason together would be a contradiction the manifest shows | |
| 598 | // to apps. | |
| 599 | try testing.expect(capability_actions[0].reason == null); | |
| 600 | } | |
| 601 | ||
| 602 | test "the action name matches the Swift case label exactly" { | |
| 603 | // The conformance ratchet compares this against the `case "…":` labels in | |
| 604 | // `CraftApp.swift` in both directions; a prettier spelling would read as | |
| 605 | // Zig serving an action the spec does not have. | |
| 606 | try testing.expectEqualStrings("getPendingNotifications", A.get_pending_notifications); | |
| 607 | } | |
| 608 | ||
| 609 | test "every declared action is one the dispatcher routes" { | |
| 610 | for (capability_actions) |decl| { | |
| 611 | if (routeFor(decl.name) == null) { | |
| 612 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 613 | return error.DeclaredActionDoesNotRoute; | |
| 614 | } | |
| 615 | } | |
| 616 | } | |
| 617 | ||
| 618 | test "every route the dispatcher has is a declared action" { | |
| 619 | // The other direction: a route with a handler and no declaration is an | |
| 620 | // action the page can call and the manifest denies exists. Each declaration | |
| 621 | // must claim a *distinct* route — counting alone would let two rows share | |
| 622 | // one route while another went undeclared. | |
| 623 | var claimed = std.mem.zeroes([std.enums.values(Route).len]bool); | |
| 624 | for (capability_actions) |decl| { | |
| 625 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 626 | const slot = @backingInt(route); | |
| 627 | if (claimed[slot]) { | |
| 628 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 629 | return error.TwoDeclarationsShareARoute; | |
| 630 | } | |
| 631 | claimed[slot] = true; | |
| 632 | } | |
| 633 | for (claimed, 0..) |taken, slot| { | |
| 634 | if (!taken) { | |
| 635 | std.debug.print( | |
| 636 | "route {s} has a handler but no capability_actions row\n", | |
| 637 | .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))}, | |
| 638 | ); | |
| 639 | return error.RouteNotDeclared; | |
| 640 | } | |
| 641 | } | |
| 642 | } | |
| 643 | ||
| 644 | test "an action the namespace does not serve is reported, not ignored" { | |
| 645 | var bridge = NotificationsBridge.init(testing.allocator); | |
| 646 | defer bridge.deinit(); | |
| 647 | ||
| 648 | try testing.expectError( | |
| 649 | bridge_error.BridgeError.UnknownAction, | |
| 650 | bridge.handleMessage("noSuchAction", "{}"), | |
| 651 | ); | |
| 652 | // Casing is how a real typo arrives. | |
| 653 | try testing.expectError( | |
| 654 | bridge_error.BridgeError.UnknownAction, | |
| 655 | bridge.handleMessage("getpendingnotifications", "{}"), | |
| 656 | ); | |
| 657 | // The JS surface method name, which is not an action name. | |
| 658 | try testing.expectError( | |
| 659 | bridge_error.BridgeError.UnknownAction, | |
| 660 | bridge.handleMessage("getPending", "{}"), | |
| 661 | ); | |
| 662 | // The Swift *helper* name, which is not a dispatcher case either — it is | |
| 663 | // spelled the same as the action and would route if the module matched on | |
| 664 | // helpers, but `getPendingNotifications` is genuinely both, so the near | |
| 665 | // miss checked here is the plural-less form. | |
| 666 | try testing.expectError( | |
| 667 | bridge_error.BridgeError.UnknownAction, | |
| 668 | bridge.handleMessage("getPendingNotification", "{}"), | |
| 669 | ); | |
| 670 | // The neighbouring cancel actions belong to `bridge_mobile_notifcancel`; | |
| 671 | // two modules answering one action would make `ios_dispatch`'s first-match | |
| 672 | // routing order-dependent. | |
| 673 | try testing.expectError( | |
| 674 | bridge_error.BridgeError.UnknownAction, | |
| 675 | bridge.handleMessage("cancelNotification", "{}"), | |
| 676 | ); | |
| 677 | try testing.expectError( | |
| 678 | bridge_error.BridgeError.UnknownAction, | |
| 679 | bridge.handleMessage("cancelAllNotifications", "{}"), | |
| 680 | ); | |
| 681 | } | |
| 682 | ||
| 683 | test "scheduleNotification is left to the Swift shim, not claimed and refused" { | |
| 684 | // This is the deliberate omission, asserted rather than left implicit. | |
| 685 | // `UnknownAction` is what makes `ios_dispatch` fall through to the host | |
| 686 | // shim, which schedules notifications correctly today. Declaring the action | |
| 687 | // `.unavailable` instead would take it away from the shim in order to | |
| 688 | // reject it, which is strictly worse for the page — see the module comment | |
| 689 | // for what `ios_async` still owes before this can be served honestly. | |
| 690 | var bridge = NotificationsBridge.init(testing.allocator); | |
| 691 | defer bridge.deinit(); | |
| 692 | ||
| 693 | try testing.expectError( | |
| 694 | bridge_error.BridgeError.UnknownAction, | |
| 695 | bridge.handleMessage("scheduleNotification", "{\"notification\":{\"title\":\"t\"}}"), | |
| 696 | ); | |
| 697 | try testing.expect(routeFor("scheduleNotification") == null); | |
| 698 | ||
| 699 | // And it must not be declared, or `capabilities` would tell an app craft | |
| 700 | // serves it. | |
| 701 | for (capability_actions) |decl| { | |
| 702 | try testing.expect(!std.mem.eql(u8, decl.name, "scheduleNotification")); | |
| 703 | } | |
| 704 | } | |
| 705 | ||
| 706 | test "the payload is ignored, not parsed" { | |
| 707 | // Swift's dispatcher reads nothing out of `body` for this action, and the | |
| 708 | // injected JS posts none. A payload that is not even JSON must therefore | |
| 709 | // reach exactly the same outcome as `{}` — if it did not, this module would | |
| 710 | // have invented a failure the shim does not have, and a page passing junk | |
| 711 | // in `d` would get INVALID_JSON where Swift gave it the pending list. | |
| 712 | // Three real dispatches, so this has to be a process the bundle guard | |
| 713 | // stops. In a runner that *does* carry a bundle identifier (tests hosted | |
| 714 | // inside an app) each call would issue a live | |
| 715 | // `getPendingNotificationRequests` and lease an async slot that no run loop | |
| 716 | // is here to release — narrowing the pool for everything after it, and | |
| 717 | // leaving side-table entries the later slot tests assert are absent. | |
| 718 | if (is_darwin) { | |
| 719 | if (requireBundleIdentifier()) |_| return error.SkipZigTest else |_| {} | |
| 720 | } | |
| 721 | ||
| 722 | var bridge = NotificationsBridge.init(testing.allocator); | |
| 723 | defer bridge.deinit(); | |
| 724 | ||
| 725 | const empty = bridge.handleMessage(A.get_pending_notifications, "{}"); | |
| 726 | const junk = bridge.handleMessage(A.get_pending_notifications, "{not json"); | |
| 727 | const structured = bridge.handleMessage(A.get_pending_notifications, "[1,2,3]"); | |
| 728 | ||
| 729 | // Whatever the platform answers, it answers the same three times, and it is | |
| 730 | // never a JSON complaint. | |
| 731 | try testing.expectEqual(empty, junk); | |
| 732 | try testing.expectEqual(empty, structured); | |
| 733 | if (empty) |_| {} else |err| { | |
| 734 | try testing.expect(err != bridge_error.BridgeError.InvalidJSON); | |
| 735 | try testing.expect(err != bridge_error.BridgeError.MissingData); | |
| 736 | } | |
| 737 | } | |
| 738 | ||
| 739 | test "an empty queue is the bare array, not null and not an error" { | |
| 740 | var list = PendingList.init(testing.allocator); | |
| 741 | defer list.deinit(); | |
| 742 | ||
| 743 | const json = try list.finish(); | |
| 744 | defer testing.allocator.free(json); | |
| 745 | ||
| 746 | try testing.expectEqualStrings("[]", json); | |
| 747 | } | |
| 748 | ||
| 749 | test "one request is the four-key object Swift builds, in a bare array" { | |
| 750 | // Swift's `requests.map` produces `["id":…, "title":…, "body":…, | |
| 751 | // "subtitle":…]` and resolves the array itself through `.fragmentsAllowed`. | |
| 752 | // `test-bridges.html` stringifies the resolved value directly, so a | |
| 753 | // wrapper object — `{"notifications":[…]}` — would change what it prints | |
| 754 | // and what `craft.d.ts`'s `Promise<PendingNotification[]>` describes. | |
| 755 | var list = PendingList.init(testing.allocator); | |
| 756 | defer list.deinit(); | |
| 757 | ||
| 758 | try list.append("notif-42", "Test Notification", "Body text", "Sub"); | |
| 759 | const json = try list.finish(); | |
| 760 | defer testing.allocator.free(json); | |
| 761 | ||
| 762 | try testing.expectEqualStrings( | |
| 763 | "[{\"id\":\"notif-42\",\"title\":\"Test Notification\",\"body\":\"Body text\",\"subtitle\":\"Sub\"}]", | |
| 764 | json, | |
| 765 | ); | |
| 766 | } | |
| 767 | ||
| 768 | test "subtitle is emitted even though craft.d.ts omits it" { | |
| 769 | // `PendingNotification` in `packages/typescript/types/craft.d.ts` is | |
| 770 | // `{id, title, body}`. Swift emits `subtitle` regardless, and Swift is the | |
| 771 | // contract this migration has to preserve — dropping the key to match a | |
| 772 | // stale type would silently remove a field an app may already read. | |
| 773 | var list = PendingList.init(testing.allocator); | |
| 774 | defer list.deinit(); | |
| 775 | ||
| 776 | try list.append("i", "t", "b", ""); | |
| 777 | const json = try list.finish(); | |
| 778 | defer testing.allocator.free(json); | |
| 779 | ||
| 780 | try testing.expect(std.mem.indexOf(u8, json, "\"subtitle\":\"\"") != null); | |
| 781 | } | |
| 782 | ||
| 783 | test "several requests are comma-separated with no trailing comma" { | |
| 784 | var list = PendingList.init(testing.allocator); | |
| 785 | defer list.deinit(); | |
| 786 | ||
| 787 | try list.append("a", "1", "x", ""); | |
| 788 | try list.append("b", "2", "y", ""); | |
| 789 | try list.append("c", "3", "z", ""); | |
| 790 | const json = try list.finish(); | |
| 791 | defer testing.allocator.free(json); | |
| 792 | ||
| 793 | try testing.expectEqualStrings( | |
| 794 | "[{\"id\":\"a\",\"title\":\"1\",\"body\":\"x\",\"subtitle\":\"\"}," ++ | |
| 795 | "{\"id\":\"b\",\"title\":\"2\",\"body\":\"y\",\"subtitle\":\"\"}," ++ | |
| 796 | "{\"id\":\"c\",\"title\":\"3\",\"body\":\"z\",\"subtitle\":\"\"}]", | |
| 797 | json, | |
| 798 | ); | |
| 799 | ||
| 800 | // Cheap structural cross-check: the fragment must parse as an array of | |
| 801 | // three objects, which is what the page's `JSON.stringify` will do to it. | |
| 802 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 803 | defer parsed.deinit(); | |
| 804 | try testing.expectEqual(@as(usize, 3), parsed.value.array.items.len); | |
| 805 | try testing.expectEqual(@as(usize, 4), parsed.value.array.items[0].object.count()); | |
| 806 | } | |
| 807 | ||
| 808 | test "notification text is escaped, not interpolated" { | |
| 809 | // These strings are app-controlled and are replayed into the source | |
| 810 | // `evaluateJavaScript:` parses. An unescaped quote or backslash would break | |
| 811 | // the page's promise resolution outright; a raw newline is invalid JSON. | |
| 812 | var list = PendingList.init(testing.allocator); | |
| 813 | defer list.deinit(); | |
| 814 | ||
| 815 | try list.append("id\"1", "say \"hi\"", "back\\slash", "line\nbreak\ttab"); | |
| 816 | const json = try list.finish(); | |
| 817 | defer testing.allocator.free(json); | |
| 818 | ||
| 819 | try testing.expectEqualStrings( | |
| 820 | "[{\"id\":\"id\\\"1\",\"title\":\"say \\\"hi\\\"\"," ++ | |
| 821 | "\"body\":\"back\\\\slash\",\"subtitle\":\"line\\nbreak\\ttab\"}]", | |
| 822 | json, | |
| 823 | ); | |
| 824 | ||
| 825 | // And it must survive a round trip with the bytes intact. | |
| 826 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 827 | defer parsed.deinit(); | |
| 828 | const first = parsed.value.array.items[0].object; | |
| 829 | try testing.expectEqualStrings("id\"1", first.get("id").?.string); | |
| 830 | try testing.expectEqualStrings("say \"hi\"", first.get("title").?.string); | |
| 831 | try testing.expectEqualStrings("back\\slash", first.get("body").?.string); | |
| 832 | try testing.expectEqualStrings("line\nbreak\ttab", first.get("subtitle").?.string); | |
| 833 | } | |
| 834 | ||
| 835 | test "the side table hands each slot's completion its own ticket, once" { | |
| 836 | // The block is global and captures nothing, so this table is the only way | |
| 837 | // a completion learns which call it is answering. Two properties matter: a | |
| 838 | // published call comes back with the generation intact (a stale generation | |
| 839 | // would make `deliverJson` a silent no-op), and taking it clears the entry, | |
| 840 | // so a double-firing completion cannot reply twice. | |
| 841 | const ticket = ios_async.Ticket{ .index = 3, .generation = 77 }; | |
| 842 | const sels = Sels{ | |
| 843 | .count = null, | |
| 844 | .object_at = null, | |
| 845 | .identifier = null, | |
| 846 | .content = null, | |
| 847 | .title = null, | |
| 848 | .body = null, | |
| 849 | .subtitle = null, | |
| 850 | .utf8 = null, | |
| 851 | .utf8_length = null, | |
| 852 | }; | |
| 853 | ||
| 854 | publishPendingCall(ticket, sels); | |
| 855 | ||
| 856 | const taken = takePendingCall(3) orelse return error.PublishedCallWentMissing; | |
| 857 | try testing.expectEqual(@as(u5, 3), taken.ticket.index); | |
| 858 | try testing.expectEqual(@as(u32, 77), taken.ticket.generation); | |
| 859 | ||
| 860 | try testing.expect(takePendingCall(3) == null); | |
| 861 | } | |
| 862 | ||
| 863 | test "a completion for a slot with no recorded call does nothing" { | |
| 864 | // A late or duplicate fire must not reach `deliverJson` at all. With the | |
| 865 | // entry cleared there is no ticket to reply with, and the block returns | |
| 866 | // without touching the pool — asserted by the neighbouring slots staying | |
| 867 | // empty rather than by observing a reply that must not happen. | |
| 868 | try testing.expect(takePendingCall(9) == null); | |
| 869 | pendingCompletionFired(9, null); | |
| 870 | try testing.expect(takePendingCall(9) == null); | |
| 871 | } | |
| 872 | ||
| 873 | test "off Darwin the handler refuses rather than fake an empty list" { | |
| 874 | if (is_darwin) return error.SkipZigTest; | |
| 875 | ||
| 876 | var bridge = NotificationsBridge.init(testing.allocator); | |
| 877 | defer bridge.deinit(); | |
| 878 | ||
| 879 | // `[]` would be a perfectly plausible-looking lie here: a page cannot tell | |
| 880 | // "no notifications are scheduled" from "this platform has no notification | |
| 881 | // center". | |
| 882 | try testing.expectError( | |
| 883 | error.UnsupportedPlatform, | |
| 884 | bridge.handleMessage(A.get_pending_notifications, "{}"), | |
| 885 | ); | |
| 886 | } | |
| 887 | ||
| 888 | test "without a bundle identifier the guard fires before the center is touched" { | |
| 889 | if (!is_darwin) return error.SkipZigTest; | |
| 890 | ||
| 891 | // The host test runner is a bare binary with no Info.plist — exactly the | |
| 892 | // process in which `currentNotificationCenter` would SIGABRT. If the guard | |
| 893 | // were ever reordered behind the center call, this test would crash the | |
| 894 | // runner rather than fail, which is still a loud answer. | |
| 895 | if (requireBundleIdentifier()) |_| { | |
| 896 | // A runner that does carry a bundle identifier (tests hosted inside an | |
| 897 | // app) would make the call below a real query against that app's | |
| 898 | // notification center; proving the guard needs the bundle-less | |
| 899 | // environment, so skip rather than pretend. | |
| 900 | return error.SkipZigTest; | |
| 901 | } else |err| switch (err) { | |
| 902 | error.NoBundleIdentifier => { | |
| 903 | var bridge = NotificationsBridge.init(testing.allocator); | |
| 904 | defer bridge.deinit(); | |
| 905 | ||
| 906 | try testing.expectError( | |
| 907 | error.NoBundleIdentifier, | |
| 908 | bridge.handleMessage(A.get_pending_notifications, "{}"), | |
| 909 | ); | |
| 910 | ||
| 911 | // And the refusal must happen before any slot is leased: a lease | |
| 912 | // that is never released narrows the pool for every later call. | |
| 913 | for (pending_calls) |entry| try testing.expect(entry == null); | |
| 914 | }, | |
| 915 | else => return err, | |
| 916 | } | |
| 917 | } | |
| 918 | ||
| 919 | test "the completion blocks are global, one per slot, and distinct" { | |
| 920 | if (!is_darwin) return error.SkipZigTest; | |
| 921 | ||
| 922 | // `getPendingNotificationRequestsWithCompletionHandler:` copies the block | |
| 923 | // and holds it until it fires. `Block_copy` is the identity on a global | |
| 924 | // block and a heap copy on a stack one, so the isa and the flag are the | |
| 925 | // whole lifetime argument. | |
| 926 | for (&array_blocks) |*b| { | |
| 927 | try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); | |
| 928 | try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); | |
| 929 | try testing.expectEqual(@as(c_ulong, @sizeOf(ArrayBlock)), b.descriptor.size); | |
| 930 | } | |
| 931 | ||
| 932 | // Each slot needs its *own* invoke, or every completion would report the | |
| 933 | // same slot index and answer the wrong caller. | |
| 934 | try testing.expect(array_blocks[0].invoke != array_blocks[1].invoke); | |
| 935 | try testing.expect(arrayBlock(.{ .index = 0, .generation = 1 }) != | |
| 936 | arrayBlock(.{ .index = 1, .generation = 1 })); | |
| 937 | } | |
| @@ -430,18 +430,39 @@ pub const PermissionsBridge = struct { | ||
| 430 | 430 | func(session, sel, ios_async.boolBlock(ticket)); |
| 431 | 431 | } |
| 432 | 432 | |
| 433 | /// Refuse before touching the notification centre in a process the | |
| 434 | /// centre will refuse to exist in. | |
| 435 | fn requireBundleIdentifier() !void { | |
| 436 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 437 | ||
| 438 | const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound; | |
| 439 | const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound; | |
| 440 | const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoBundleIdentifier; | |
| 441 | const sel_ident = objc.sel_registerName("bundleIdentifier") orelse return error.SelectorNotFound; | |
| 442 | if (objc.msgSendId(bundle, sel_ident) == null) return error.NoBundleIdentifier; | |
| 443 | } | |
| 444 | ||
| 433 | 445 | /// `-[UNUserNotificationCenter requestAuthorizationWithOptions:completionHandler:]` |
| 434 | 446 | /// with a `void(^)(BOOL, NSError *)` from the pool. The error object is |
| 435 | 447 | /// discarded by the pool's invoke, exactly as Swift's `{ granted, _ in }` |
| 436 | 448 | /// discards it — the granted flag is the whole page contract. |
| 437 | 449 | /// |
| 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. | |
| 450 | /// `currentNotificationCenter` RAISES — an uncatchable | |
| 451 | /// `NSInternalInconsistencyException`, not a nil return — in a process | |
| 452 | /// with no bundle identifier, so the bundle is checked first. | |
| 453 | /// | |
| 454 | /// The previous comment here argued the guard was unnecessary because a | |
| 455 | /// dispatched page message only exists inside a bundled app. That is true | |
| 456 | /// of the shipping app and false of every test runner, and "the Swift call | |
| 457 | /// has the same exposure" is not a reason to inherit a crash: an | |
| 458 | /// unguarded call takes the process down with the page's promise still | |
| 459 | /// pending, which is the least diagnosable failure iOS offers. | |
| 460 | /// `bridge_mobile_notifcancel.zig` guards the same call the same way. | |
| 442 | 461 | fn requestNotifications() !void { |
| 443 | 462 | if (!is_darwin) return error.UnsupportedPlatform; |
| 444 | 463 | |
| 464 | try requireBundleIdentifier(); | |
| 465 | ||
| 445 | 466 | const UNUserNotificationCenter = objc.objc_getClass("UNUserNotificationCenter") orelse |
| 446 | 467 | return error.ClassNotFound; |
| 447 | 468 | const sel_current = objc.sel_registerName("currentNotificationCenter") orelse |
packages/zig/src/bridge_mobile_watch.zigadded+1214-0
| @@ -0,0 +1,1214 @@ | ||
| 1 | //! The WatchConnectivity actions of the `mobile` namespace that Zig can serve | |
| 2 | //! honestly: `updateWatchContext` and `isWatchReachable`. | |
| 3 | //! | |
| 4 | //! `sendToWatch` is deliberately **not** in the `A` block — see the last | |
| 5 | //! section. It is not `.unavailable`; it is absent, so `ios_dispatch`'s | |
| 6 | //! first-match chain falls through to the Swift shim that serves it correctly | |
| 7 | //! today. | |
| 8 | //! | |
| 9 | //! One JS surface reaches these: the Swift-injected `window.craft.watch` | |
| 10 | //! object. `updateContext(context)` posts `{action:'updateWatchContext', | |
| 11 | //! context: context, callbackId: id}`; `isReachable()` posts the action alone. | |
| 12 | //! Neither promise goes through `_createCallback`, so neither has a timeout — | |
| 13 | //! a dropped message parks the page forever, which is why every path here ends | |
| 14 | //! in a reply or an error. | |
| 15 | //! | |
| 16 | //! ## What is carried across exactly, because it is the observable contract | |
| 17 | //! | |
| 18 | //! - **Both replies are objects, not bare fragments.** This is the one place | |
| 19 | //! this module differs from every mobile module migrated before it. Swift | |
| 20 | //! serialises with `.fragmentsAllowed`, which *permits* a bare value but | |
| 21 | //! does not *unwrap* a dictionary, and both of these Swift paths resolve | |
| 22 | //! dictionaries: `["reachable": reachable]` and `["updated": true]`. So the | |
| 23 | //! wire shapes are `{"reachable":true}` / `{"reachable":false}` and | |
| 24 | //! `{"updated":true}` — never `true` / `false`. Both consumers depend on the | |
| 25 | //! wrapper: `craft.d.ts` types them `Promise<{reachable: boolean}>` and | |
| 26 | //! `Promise<{updated: boolean}>`, and `test-bridges.html` prints | |
| 27 | //! `JSON.stringify(result)`, which would render `false` instead of | |
| 28 | //! `{"reachable":false}` if the wrapper were dropped. `result.reachable` | |
| 29 | //! would be `undefined` in every real app. The reply-shape tests below pin | |
| 30 | //! the wrapper explicitly, because "match the sibling modules" is exactly | |
| 31 | //! the wrong instinct here. | |
| 32 | //! - **The payload field is `context`** — not `applicationContext`, not | |
| 33 | //! `data`. That is what the injected JS posts and what the Swift shim reads | |
| 34 | //! (`body["context"] as? [String: Any]`), pinned on both sides of the | |
| 35 | //! migration. The `callbackId` also present is correlation plumbing the Zig | |
| 36 | //! envelope replaces with `i`; it is ignored, not consumed. | |
| 37 | //! - **The context is carried whole and nested.** Swift's hand-off path | |
| 38 | //! rebuilds it with `JSONSerialization.jsonObject(with:)`, so nested values | |
| 39 | //! arrive as Foundation containers; `toFoundationDictionary` below takes the | |
| 40 | //! same byte route, and produces the same Foundation types. | |
| 41 | //! `updateApplicationContext:` requires property list types, and all but | |
| 42 | //! one of those are: `NSString`, `NSNumber`, `NSArray` and `NSDictionary`. | |
| 43 | //! The exception is `NSNull`, which a JSON `null` becomes and which is | |
| 44 | //! *not* a property list type — a context carrying one is refused with | |
| 45 | //! `WCErrorCodePayloadUnsupportedTypes`, mapped below to | |
| 46 | //! `InvalidParameter`. That is not a divergence: WebKit hands Swift the | |
| 47 | //! same `NSNull` for the same `null`, so the shim's | |
| 48 | //! `updateApplicationContext` throws on it too. | |
| 49 | //! - **An empty context is legal.** `WCSession.h`: "If there is no app | |
| 50 | //! context, it should be updated with an empty dictionary." `{}` is passed | |
| 51 | //! through, not refused. | |
| 52 | //! - **A missing or unactivated session answers `{"reachable":false}`,** not | |
| 53 | //! an error. Swift's `wcSession?.isReachable ?? false` says the same thing | |
| 54 | //! for the same reason: no session, no reachable watch. | |
| 55 | //! | |
| 56 | //! ## What is deliberately not carried across | |
| 57 | //! | |
| 58 | //! **Swift's silent hang.** The `updateWatchContext` dispatcher arm is | |
| 59 | //! `if let context = body["context"] as? [String: Any]` with **no `else`**: a | |
| 60 | //! missing `context`, or a `context` that is not an object, replies nothing at | |
| 61 | //! all and the untimed promise never settles. Malformed input errors here | |
| 62 | //! instead — `MissingData` when the field is absent, `InvalidParameter` when it | |
| 63 | //! is present but not an object. Same divergence, same argument, as | |
| 64 | //! `bridge_mobile_notifcancel.zig`'s missing-`id` note. | |
| 65 | //! | |
| 66 | //! **The rejection *text*.** `sendErrorToJS` carries a `BridgeError` enum; | |
| 67 | //! there is no free-text channel. Swift rejects with | |
| 68 | //! `error.localizedDescription` ("The payload contained unsupported types.", | |
| 69 | //! "The session is not activated.") and `test-bridges.html` prints | |
| 70 | //! `e.message`. This is a precedented loss, not a new one — | |
| 71 | //! `bridge_mobile_misc.zig` documents the identical trade for `setFlashlight`, | |
| 72 | //! and `bridge_mobile_securestore.zig` for its OSStatus mapping. The specifics | |
| 73 | //! go to the log: `updateApplicationContext:error:` is called with a real | |
| 74 | //! `NSError` out-parameter (unlike `misc`'s null one) precisely because the | |
| 75 | //! `WCErrorCode` is genuinely diagnostic, and the domain, code and | |
| 76 | //! `localizedDescription` are all logged before the enum is chosen. | |
| 77 | //! | |
| 78 | //! **The `enableWatchApp` config gate.** Swift only ever calls | |
| 79 | //! `setupWatchConnectivity` under `if config.enableWatchApp`, and that flag has | |
| 80 | //! no Zig mirror — it appears nowhere in `packages/zig/src`. Both actions are | |
| 81 | //! therefore served unconditionally, exactly as `securestore` and `notifcancel` | |
| 82 | //! document for their own gates. This grants a page nothing it should not have: | |
| 83 | //! with the gate off Swift never activated a session, so `defaultSession`'s | |
| 84 | //! `activationState` is `NotActivated` and both actions answer honestly — | |
| 85 | //! `{"reachable":false}` and a refusal to update. | |
| 86 | //! | |
| 87 | //! ## Read-only: no `activateSession`, no `setDelegate:`, ever | |
| 88 | //! | |
| 89 | //! `WCSession` is a singleton, so when Swift's `setupWatchConnectivity` ran, | |
| 90 | //! `+[WCSession defaultSession]` hands Zig the very same object Swift already | |
| 91 | //! delegated and activated. Reading it is safe. Activating it from Zig is not, | |
| 92 | //! twice over: | |
| 93 | //! | |
| 94 | //! - `WCSession.h` says "A delegate must exist before the session will allow | |
| 95 | //! sends" and that calling activate without one is undefined. Zig cannot | |
| 96 | //! supply a `WCSessionDelegate` without `objc_allocateClassPair` plus three | |
| 97 | //! `@required` methods. | |
| 98 | //! - It would stomp the Swift delegate that feeds the `craftWatchMessage` and | |
| 99 | //! `craftWatchReachability` events the page already listens for. | |
| 100 | //! | |
| 101 | //! So `resolveSession` reads `isSupported`, `defaultSession` and | |
| 102 | //! `activationState` and stops. The `activationState` check is a deliberate, | |
| 103 | //! tiny divergence from Swift's plain nil-check: the header says the session | |
| 104 | //! "must be activated on startup before the session's properties contain | |
| 105 | //! correct values", so reading `isReachable` on an unactivated session reads an | |
| 106 | //! undefined value. In every case where the guard changes the answer, Swift's | |
| 107 | //! answer was `false` too (its ivar was nil) — and for `updateWatchContext` it | |
| 108 | //! turns the common "watch support was never enabled" case into a nameable | |
| 109 | //! refusal instead of a generic `WCErrorCodeSessionNotActivated`. | |
| 110 | //! | |
| 111 | //! ## No bundle-identifier guard, and no exception hazard | |
| 112 | //! | |
| 113 | //! Do not copy `bridge_mobile_notifcancel.zig`'s bundle guard reflexively. That | |
| 114 | //! exists because `[UNUserNotificationCenter currentNotificationCenter]` | |
| 115 | //! *raises* `NSInternalInconsistencyException` in a process with no bundle id, | |
| 116 | //! and an uncaught ObjC exception is an uncatchable SIGABRT. Every | |
| 117 | //! `WCSession` failure mode reached here is a `nil`, a `NO`, or an `NSError`: | |
| 118 | //! `WCError.h` defines `SessionMissingDelegate = 7003`, | |
| 119 | //! `SessionNotActivated = 7004`, `DeviceNotPaired = 7005`, | |
| 120 | //! `WatchAppNotInstalled = 7006`, `NotReachable = 7007`, | |
| 121 | //! `InvalidParameter = 7008`, `PayloadTooLarge = 7009`, | |
| 122 | //! `PayloadUnsupportedTypes = 7010` — **error codes, not exceptions**. Calling | |
| 123 | //! `updateApplicationContext:error:` on an unactivated or delegate-less session | |
| 124 | //! returns `NO` and an `NSError`; it does not raise. | |
| 125 | //! | |
| 126 | //! `WCSession.h` is `API_UNAVAILABLE(macos)`, so on a native macOS host test | |
| 127 | //! runner `objc_getClass("WCSession")` returns null. That is not a failure — | |
| 128 | //! it is the honest `{"reachable":false}`, identical to Swift's nil ivar, and | |
| 129 | //! it is the one live Objective-C path the host tests below can actually | |
| 130 | //! exercise. `build.zig` links no `WatchConnectivity` framework anywhere; in | |
| 131 | //! the Swift-hosted app the framework is in the process because `CraftApp.swift` | |
| 132 | //! does `import WatchConnectivity`, so `objc_getClass` resolves. In a | |
| 133 | //! hypothetical Zig-only app it would not, and `isWatchReachable` would report | |
| 134 | //! `false` forever — honest, and worth knowing. | |
| 135 | //! | |
| 136 | //! ## Why `sendToWatch` is absent rather than `.unavailable` | |
| 137 | //! | |
| 138 | //! `-[WCSession sendMessage:replyHandler:errorHandler:]` takes two | |
| 139 | //! one-argument blocks, and `WCSession.h` guarantees that **exactly one of them | |
| 140 | //! is invoked** and that both fire on "a non-main serial queue". Zig can build | |
| 141 | //! the reply-handler half: a global block plus `ios_async.deliverJson` would | |
| 142 | //! resolve the watch's dictionary correctly. | |
| 143 | //! | |
| 144 | //! It cannot build the error half. `ios_async` has no error-delivery path — | |
| 145 | //! `deliverOnMain` ends unconditionally in `bridge_error.sendResultToJS`, and | |
| 146 | //! `sendErrorToJS` appears nowhere in that file. Every available move is a rule | |
| 147 | //! violation: | |
| 148 | //! | |
| 149 | //! - resolve something on the error path → fabricated success; | |
| 150 | //! - `ios_async.abandon` the ticket → silence on an untimed promise, the exact | |
| 151 | //! bug `ios_async` exists to prevent; | |
| 152 | //! - call `craft_ios_deliver_error` from the block → that export does not hop | |
| 153 | //! to the main queue, so it would reach `evaluateJavaScript` from | |
| 154 | //! WatchConnectivity's background delegate queue. | |
| 155 | //! | |
| 156 | //! And the error path is the *common* path here, not the exotic one: unpaired | |
| 157 | //! simulator, watch app not installed, not reachable, reply timed out. The | |
| 158 | //! bundled `CraftWatchApp.swift.template` implements only | |
| 159 | //! `session(_:didReceiveMessage:)` — the no-reply variant — so against the | |
| 160 | //! shipped companion the reply handler never fires and the error handler always | |
| 161 | //! does. | |
| 162 | //! | |
| 163 | //! The Swift shim serves all of this correctly today: its reply handler routes | |
| 164 | //! through `deliverResultIfHandOff` and its error handler through | |
| 165 | //! `deliverErrorIfHandOff`, rejecting the page's promise with the real | |
| 166 | //! `localizedDescription`. Declaring the action `.unavailable` would *steal* it | |
| 167 | //! from that shim and turn every call into a refusal, which is strictly worse | |
| 168 | //! than what ships now. So the action is omitted from `A` entirely and falls | |
| 169 | //! through. | |
| 170 | //! | |
| 171 | //! Unblocking condition, precisely: `sendToWatch` becomes migratable the moment | |
| 172 | //! `ios_async` gains a `deliverError(ticket, BridgeError)` — a slot field plus | |
| 173 | //! one branch in `deliverOnMain` — at which point it still costs the | |
| 174 | //! `localizedDescription` text, and must additionally call | |
| 175 | //! `+[NSJSONSerialization isValidJSONObject:]` before serialising the watch's | |
| 176 | //! reply. Swift does not: `resolveCallback` hands the reply straight to | |
| 177 | //! `dataWithJSONObject:`, which *raises* `NSInvalidArgumentException` for the | |
| 178 | //! `NSDate`/`NSData` values `WCSession` permits, and Swift's `catch` cannot | |
| 179 | //! catch that. A watch replying `["at": Date()]` aborts the app today; Zig must | |
| 180 | //! not reproduce it. | |
| 181 | ||
| 182 | const std = ; | |
| 183 | const builtin = ; | |
| 184 | const capabilities = ; | |
| 185 | const bridge_error = ; | |
| 186 | const objc_runtime = ; | |
| 187 | ||
| 188 | const objc = objc_runtime.objc; | |
| 189 | const is_darwin = builtin.target.os.tag.isDarwin(); | |
| 190 | ||
| 191 | /// The same type as `objc.id` — `?*anyopaque` — spelled locally. | |
| 192 | /// | |
| 193 | /// `objc_runtime.objc` is an empty struct off Darwin, and a function | |
| 194 | /// *signature* is analysed even when a comptime platform guard makes its body | |
| 195 | /// unreachable, so naming `objc.id` in the `callconv(.c)` types below would | |
| 196 | /// break the host build. It stays a single optional pointer, never `?objc.id`: | |
| 197 | /// a double optional is illegal in a `callconv(.c)` type. | |
| 198 | const Id = ?*anyopaque; | |
| 199 | ||
| 200 | /// The action names, spelled exactly as the Swift `case` labels spell them. | |
| 201 | /// `test/ios_conformance_test.zig` matches the two lists by string in both | |
| 202 | /// directions. | |
| 203 | /// | |
| 204 | /// `sendToWatch` is absent on purpose — the module comment's last section | |
| 205 | /// gives the full argument. An action listed here is an action this module | |
| 206 | /// takes away from the Swift shim, and that trade is only worth making when | |
| 207 | /// Zig's answer is at least as good. | |
| 208 | pub const A = struct { | |
| 209 | pub const update_watch_context = "updateWatchContext"; | |
| 210 | pub const is_watch_reachable = "isWatchReachable"; | |
| 211 | }; | |
| 212 | ||
| 213 | /// Both `.result`: each Swift path terminates in exactly one | |
| 214 | /// `resolveCallback`, and both injected JS methods return untimed promises the | |
| 215 | /// page awaits — `.none` here would strand a caller forever, not for thirty | |
| 216 | /// seconds. | |
| 217 | /// | |
| 218 | /// Both `.live`. Neither needs a permission prompt, an entitlement or a | |
| 219 | /// completion handler: reading `isReachable` and calling | |
| 220 | /// `updateApplicationContext:error:` are synchronous. `.unavailable` is for an | |
| 221 | /// action that dispatches and refuses, and neither of these does. | |
| 222 | pub const capability_actions = [_]capabilities.ActionDecl{ | |
| 223 | .{ .name = A.update_watch_context, .reply = .result }, | |
| 224 | .{ .name = A.is_watch_reachable, .reply = .result }, | |
| 225 | }; | |
| 226 | ||
| 227 | // ----------------------------------------------------------------------------- | |
| 228 | // Reply fragments. | |
| 229 | // | |
| 230 | // Objects, not bare booleans. Swift resolves `["reachable": …]` and | |
| 231 | // `["updated": true]` dictionaries; `.fragmentsAllowed` permits a bare value | |
| 232 | // but never unwraps a dictionary into one. Static, so replying allocates | |
| 233 | // nothing. | |
| 234 | // ----------------------------------------------------------------------------- | |
| 235 | ||
| 236 | const reachable_true = "{\"reachable\":true}"; | |
| 237 | const reachable_false = "{\"reachable\":false}"; | |
| 238 | ||
| 239 | /// `updateWatchContext` has no `false` reply: Swift's failure path *rejects*, | |
| 240 | /// it does not resolve `{"updated":false}`. | |
| 241 | const updated_true = "{\"updated\":true}"; | |
| 242 | ||
| 243 | fn reachableFragment(reachable: bool) []const u8 { | |
| 244 | return if (reachable) reachable_true else reachable_false; | |
| 245 | } | |
| 246 | ||
| 247 | /// Which handler an action selects, split out from `handleMessage` so the | |
| 248 | /// table-versus-dispatch agreement is assertable on a host, where | |
| 249 | /// `WCSession` does not exist. | |
| 250 | const Route = enum { update_context, is_reachable }; | |
| 251 | ||
| 252 | fn routeFor(action: []const u8) ?Route { | |
| 253 | if (std.mem.eql(u8, action, A.update_watch_context)) return .update_context; | |
| 254 | if (std.mem.eql(u8, action, A.is_watch_reachable)) return .is_reachable; | |
| 255 | return null; | |
| 256 | } | |
| 257 | ||
| 258 | pub const WatchBridge = struct { | |
| 259 | allocator: std.mem.Allocator, | |
| 260 | ||
| 261 | const Self = @This(); | |
| 262 | ||
| 263 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 264 | return .{ .allocator = allocator }; | |
| 265 | } | |
| 266 | ||
| 267 | pub fn deinit(_: *Self) void {} | |
| 268 | ||
| 269 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 270 | const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction; | |
| 271 | // Exhaustive, so a `Route` without a handler is a compile error. | |
| 272 | return switch (route) { | |
| 273 | .update_context => self.updateWatchContext(data), | |
| 274 | .is_reachable => self.isWatchReachable(), | |
| 275 | }; | |
| 276 | } | |
| 277 | ||
| 278 | /// Push a new application context to the paired watch. | |
| 279 | /// | |
| 280 | /// Validation runs before any Objective-C, so a payload the handler must | |
| 281 | /// refuse is refused identically on every platform and the host tests can | |
| 282 | /// pin it without a `WCSession` in sight. | |
| 283 | fn updateWatchContext(self: *Self, data: []const u8) !void { | |
| 284 | var parsed = try parsePayload(self.allocator, data); | |
| 285 | defer parsed.deinit(); | |
| 286 | ||
| 287 | const context = try parseContext(parsed.value); | |
| 288 | ||
| 289 | const session = switch (try resolveSession()) { | |
| 290 | .active => |s| s, | |
| 291 | .absent => |why| { | |
| 292 | // Swift rejects here too ("Watch session not available"), and | |
| 293 | // its message is the thing this bridge cannot carry — so the | |
| 294 | // specific reason goes to the log and the page gets the one | |
| 295 | // code that fits: there is no session to update. | |
| 296 | std.log.warn( | |
| 297 | "updateWatchContext: no usable WCSession ({s}); refusing rather than reporting an update that did not happen", | |
| 298 | .{@tagName(why)}, | |
| 299 | ); | |
| 300 | return bridge_error.BridgeError.NotFound; | |
| 301 | }, | |
| 302 | }; | |
| 303 | ||
| 304 | try pushApplicationContext(self.allocator, session, context); | |
| 305 | ||
| 306 | bridge_error.sendResultToJS(self.allocator, A.update_watch_context, updated_true); | |
| 307 | } | |
| 308 | ||
| 309 | /// Answer whether the paired watch is reachable right now. | |
| 310 | /// | |
| 311 | /// Never an error on a working iOS device: an absent, unsupported or | |
| 312 | /// unactivated session is `{"reachable":false}`, which is both true and | |
| 313 | /// exactly what Swift's `wcSession?.isReachable ?? false` returns. The | |
| 314 | /// payload is ignored, as Swift ignores the body — the injected JS posts | |
| 315 | /// the action alone and `ios_dispatch.payloadOf` hands this `"{}"`. | |
| 316 | fn isWatchReachable(self: *Self) !void { | |
| 317 | const reachable = switch (try resolveSession()) { | |
| 318 | .active => |s| readIsReachable(s), | |
| 319 | .absent => |why| blk: { | |
| 320 | std.log.info("isWatchReachable: no usable WCSession ({s}); answering false", .{@tagName(why)}); | |
| 321 | break :blk false; | |
| 322 | }, | |
| 323 | }; | |
| 324 | ||
| 325 | bridge_error.sendResultToJS(self.allocator, A.is_watch_reachable, reachableFragment(reachable)); | |
| 326 | } | |
| 327 | }; | |
| 328 | ||
| 329 | // ============================================================================= | |
| 330 | // Payload parsing. Pure, so every outcome Swift's `as? [String: Any]` | |
| 331 | // collapsed into a silent hang is pinnable on a host. | |
| 332 | // ============================================================================= | |
| 333 | ||
| 334 | /// Parse `d`, distinguishing a bad payload from a failed allocation — telling | |
| 335 | /// the page INVALID_JSON about its own good JSON sends whoever debugs it to | |
| 336 | /// the wrong side of the bridge. | |
| 337 | fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) { | |
| 338 | return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) { | |
| 339 | error.OutOfMemory => return err, | |
| 340 | else => return bridge_error.BridgeError.InvalidJSON, | |
| 341 | }; | |
| 342 | } | |
| 343 | ||
| 344 | /// The `context` field as a JSON object, or the reason it cannot be used. | |
| 345 | /// | |
| 346 | /// No `requireNulFree` here, and that is deliberate rather than an omission. | |
| 347 | /// The sibling modules refuse embedded NULs because their route to Foundation | |
| 348 | /// is `stringWithUTF8String:`, which truncates. This one re-serialises to bytes | |
| 349 | /// and goes through `+[NSJSONSerialization JSONObjectWithData:options:error:]`, | |
| 350 | /// which is length-based: a NUL in a key or a value survives intact, so there | |
| 351 | /// is nothing to refuse. `bridge_mobile_shortcuts.zig` documents the same | |
| 352 | /// exemption for `userInfo`. | |
| 353 | fn parseContext(payload: std.json.Value) !std.json.Value { | |
| 354 | const object = switch (payload) { | |
| 355 | .object => |o| o, | |
| 356 | else => return bridge_error.BridgeError.InvalidJSON, | |
| 357 | }; | |
| 358 | const field = object.get("context") orelse return bridge_error.BridgeError.MissingData; | |
| 359 | return switch (field) { | |
| 360 | .object => field, | |
| 361 | // Swift's `as? [String: Any]` fails here and replies nothing at all. | |
| 362 | // A coercion would be worse: `updateApplicationContext:` takes a | |
| 363 | // dictionary, and wrapping a string in one would sync a context the | |
| 364 | // page never asked for and report `{"updated":true}` for it. | |
| 365 | else => bridge_error.BridgeError.InvalidParameter, | |
| 366 | }; | |
| 367 | } | |
| 368 | ||
| 369 | /// Re-serialise a parsed value to JSON bytes for `NSJSONSerialization`. | |
| 370 | /// | |
| 371 | /// `std.json.Value` does not remember its source span, so this walk exists. | |
| 372 | /// Duplicated from `bridge_mobile_shortcuts.zig`, where it is file-private by | |
| 373 | /// design — the same way `securestore` duplicated its externs rather than | |
| 374 | /// widening another module's surface. | |
| 375 | /// | |
| 376 | /// Strings and keys go through `bridge_error.appendJsonEscaped`, which escapes | |
| 377 | /// every control byte including NUL; an unescaped control byte would make the | |
| 378 | /// bytes invalid JSON that Foundation refuses, turning a legal context into a | |
| 379 | /// refusal. | |
| 380 | /// | |
| 381 | /// The `.float` arm refuses a non-finite value: `{d}` would render `inf`, | |
| 382 | /// which is not JSON, and the failure would otherwise surface as an opaque nil | |
| 383 | /// from Foundation. That arm is a guard against a hand-built `Value`, not | |
| 384 | /// against a payload — `std.json` never parses a number into a non-finite | |
| 385 | /// `.float`. See the `.number_string` arm for where an out-of-range literal | |
| 386 | /// actually lands. | |
| 387 | fn serializeJsonValue( | |
| 388 | allocator: std.mem.Allocator, | |
| 389 | out: *std.ArrayListUnmanaged(u8), | |
| 390 | value: std.json.Value, | |
| 391 | ) !void { | |
| 392 | switch (value) { | |
| 393 | .null => try out.appendSlice(allocator, "null"), | |
| 394 | .bool => |b| try out.appendSlice(allocator, if (b) "true" else "false"), | |
| 395 | .integer => |i| { | |
| 396 | var buf: [24]u8 = undefined; | |
| 397 | try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{i})); | |
| 398 | }, | |
| 399 | .float => |f| { | |
| 400 | if (!std.math.isFinite(f)) return bridge_error.BridgeError.InvalidParameter; | |
| 401 | // `{d}` renders decimal notation, never scientific, and a finite | |
| 402 | // f64 can need up to `bufferSize(.decimal, f64)` (347) bytes — | |
| 403 | // 1e300 alone is 301 digits. A smaller buffer turns that legal | |
| 404 | // JSON number into a NoSpaceLeft refusal. | |
| 405 | var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined; | |
| 406 | try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{f})); | |
| 407 | }, | |
| 408 | // Reachable under the *default* options, contrary to what the copy of | |
| 409 | // this walk in `bridge_mobile_shortcuts.zig` claims: `std.json` falls | |
| 410 | // back to `.number_string` for an integer literal that overflows `i64` | |
| 411 | // (`99999999999999999999`) and for a float literal that parses | |
| 412 | // non-finite (`1e400`). The bytes are the scanner-validated source | |
| 413 | // token, so re-emitting them verbatim is both valid JSON and the only | |
| 414 | // lossless answer — rendering them through `{d}` would round | |
| 415 | // `99999999999999999999` to a different number, and refusing them | |
| 416 | // would drop a field Swift delivers. (Swift delivers it *rounded*: | |
| 417 | // WebKit hands the shim a JS double, so the legacy path loses the | |
| 418 | // extra digits this one keeps. Keeping more of the page's payload is | |
| 419 | // the safe direction to differ in.) | |
| 420 | .number_string => |s| try out.appendSlice(allocator, s), | |
| 421 | .string => |s| { | |
| 422 | try out.append(allocator, '"'); | |
| 423 | try bridge_error.appendJsonEscaped(allocator, out, s); | |
| 424 | try out.append(allocator, '"'); | |
| 425 | }, | |
| 426 | .array => |items| { | |
| 427 | try out.append(allocator, '['); | |
| 428 | for (items.items, 0..) |item, i| { | |
| 429 | if (i > 0) try out.append(allocator, ','); | |
| 430 | try serializeJsonValue(allocator, out, item); | |
| 431 | } | |
| 432 | try out.append(allocator, ']'); | |
| 433 | }, | |
| 434 | .object => |obj| { | |
| 435 | try out.append(allocator, '{'); | |
| 436 | var it = obj.iterator(); | |
| 437 | var first = true; | |
| 438 | while (it.next()) |entry| { | |
| 439 | if (!first) try out.append(allocator, ','); | |
| 440 | first = false; | |
| 441 | try out.append(allocator, '"'); | |
| 442 | try bridge_error.appendJsonEscaped(allocator, out, entry.key_ptr.*); | |
| 443 | try out.appendSlice(allocator, "\":"); | |
| 444 | try serializeJsonValue(allocator, out, entry.value_ptr.*); | |
| 445 | } | |
| 446 | try out.append(allocator, '}'); | |
| 447 | }, | |
| 448 | } | |
| 449 | } | |
| 450 | ||
| 451 | // ============================================================================= | |
| 452 | // WCError mapping. Pure and table-driven, so the choice of code is reviewable | |
| 453 | // without a paired watch. | |
| 454 | // ============================================================================= | |
| 455 | ||
| 456 | /// `WCErrorDomain`, spelled out rather than read from the framework's global: | |
| 457 | /// the symbol only exists when WatchConnectivity is linked, and this comparison | |
| 458 | /// must work in the host build too. | |
| 459 | const wc_error_domain = "WCErrorDomain"; | |
| 460 | ||
| 461 | // `WCError.h`, verbatim. Only the codes this module can distinguish are named; | |
| 462 | // the rest fall to the catch-all deliberately. | |
| 463 | const wc_device_not_paired: c_long = 7005; | |
| 464 | const wc_watch_app_not_installed: c_long = 7006; | |
| 465 | const wc_invalid_parameter: c_long = 7008; | |
| 466 | const wc_payload_too_large: c_long = 7009; | |
| 467 | const wc_payload_unsupported_types: c_long = 7010; | |
| 468 | ||
| 469 | /// The nearest `BridgeError` for an `NSError` from | |
| 470 | /// `updateApplicationContext:error:`. | |
| 471 | /// | |
| 472 | /// Domain-checked before the code is read, because `NSError` codes are | |
| 473 | /// domain-scoped: a `7008` from some other domain means something else | |
| 474 | /// entirely, and mapping it to `InvalidParameter` would blame the page for a | |
| 475 | /// failure that was not its payload's fault. | |
| 476 | /// | |
| 477 | /// The mapping is lossy in exactly the way `bridge_mobile_misc.zig` and | |
| 478 | /// `bridge_mobile_securestore.zig` already are — `sendErrorToJS` carries an | |
| 479 | /// enum, not text — so the caller logs the domain, code and | |
| 480 | /// `localizedDescription` before calling this. | |
| 481 | fn bridgeErrorForNSError(domain: ?[]const u8, code: c_long) bridge_error.BridgeError { | |
| 482 | const d = domain orelse return bridge_error.BridgeError.NativeCallFailed; | |
| 483 | if (!std.mem.eql(u8, d, wc_error_domain)) return bridge_error.BridgeError.NativeCallFailed; | |
| 484 | ||
| 485 | return switch (code) { | |
| 486 | // The page sent something WatchConnectivity will not carry. Its | |
| 487 | // payload is the thing to change, so name it. | |
| 488 | wc_invalid_parameter, | |
| 489 | wc_payload_too_large, | |
| 490 | wc_payload_unsupported_types, | |
| 491 | => bridge_error.BridgeError.InvalidParameter, | |
| 492 | ||
| 493 | // There is no watch on the other end to receive the context. | |
| 494 | wc_device_not_paired, | |
| 495 | wc_watch_app_not_installed, | |
| 496 | => bridge_error.BridgeError.NotFound, | |
| 497 | ||
| 498 | // Everything else, including SessionNotActivated (7004) and | |
| 499 | // SessionMissingDelegate (7003), which `resolveSession` normally | |
| 500 | // catches first. | |
| 501 | else => bridge_error.BridgeError.NativeCallFailed, | |
| 502 | }; | |
| 503 | } | |
| 504 | ||
| 505 | // ============================================================================= | |
| 506 | // The Objective-C half. Read-only on the session; see the module comment. | |
| 507 | // | |
| 508 | // Main thread throughout: `ios_dispatch.handleMessage` runs synchronously from | |
| 509 | // `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback WebKit | |
| 510 | // delivers on the main thread — the reasoning is written out at | |
| 511 | // `bridge_mobile_display.zig`. Both calls here are synchronous, so no | |
| 512 | // `ios_async` ticket and no main-queue hop are involved; the only action in | |
| 513 | // this namespace with a completion handler is the one this module does not | |
| 514 | // serve. | |
| 515 | // ============================================================================= | |
| 516 | ||
| 517 | /// `WCSessionActivationState`. `NotActivated = 0, Inactive = 1, Activated = 2`. | |
| 518 | const activation_state_activated: c_long = 2; | |
| 519 | ||
| 520 | /// Why there is no session to talk to. Each variant is a distinct, nameable | |
| 521 | /// cause the log can print; the page sees one answer for all of them, because | |
| 522 | /// Swift gave one answer for all of them too. | |
| 523 | const NoSession = enum { | |
| 524 | /// `objc_getClass("WCSession")` was null — WatchConnectivity is not in the | |
| 525 | /// process. Always the case on a macOS host: `WCSession.h` is | |
| 526 | /// `API_UNAVAILABLE(macos)`. | |
| 527 | framework_absent, | |
| 528 | /// `+[WCSession isSupported]` said NO — iPad, or another device with no | |
| 529 | /// watch pairing support. | |
| 530 | unsupported_device, | |
| 531 | /// `+[WCSession defaultSession]` returned nil, which the header does not | |
| 532 | /// document but which costs one branch to survive. | |
| 533 | no_default_session, | |
| 534 | /// The singleton exists but nobody activated it — Swift's | |
| 535 | /// `setupWatchConnectivity` never ran, which is what `enableWatchApp: | |
| 536 | /// false` looks like from here. Its properties are undefined until | |
| 537 | /// activation, so they are not read. | |
| 538 | not_activated, | |
| 539 | }; | |
| 540 | ||
| 541 | const Session = union(enum) { | |
| 542 | absent: NoSession, | |
| 543 | active: *anyopaque, | |
| 544 | }; | |
| 545 | ||
| 546 | /// The default `WCSession`, if there is one that has been activated. | |
| 547 | /// | |
| 548 | /// `.absent` is a real answer, not a failure: both callers have something | |
| 549 | /// honest to do with it. Only the platform gate is an error, because off | |
| 550 | /// Darwin there is no question to answer. | |
| 551 | fn resolveSession() !Session { | |
| 552 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 553 | ||
| 554 | const WCSession = objc.objc_getClass("WCSession") orelse | |
| 555 | return .{ .absent = .framework_absent }; | |
| 556 | ||
| 557 | const sel_supported = objc.sel_registerName("isSupported") orelse return error.SelectorNotFound; | |
| 558 | if (!objc.msgSendBool(WCSession, sel_supported)) return .{ .absent = .unsupported_device }; | |
| 559 | ||
| 560 | const sel_default = objc.sel_registerName("defaultSession") orelse return error.SelectorNotFound; | |
| 561 | const session = objc.msgSendId(WCSession, sel_default) orelse | |
| 562 | return .{ .absent = .no_default_session }; | |
| 563 | ||
| 564 | // `activationState` is an `NSInteger` property; `objc_runtime.zig` has no | |
| 565 | // integer-returning helper, so the cast is local. | |
| 566 | const sel_state = objc.sel_registerName("activationState") orelse return error.SelectorNotFound; | |
| 567 | const StateFn = *const fn (Id, objc.SEL) callconv(.c) c_long; | |
| 568 | const state_fn: StateFn = @ptrCast(&objc.objc_msgSend); | |
| 569 | if (state_fn(session, sel_state) != activation_state_activated) { | |
| 570 | return .{ .absent = .not_activated }; | |
| 571 | } | |
| 572 | ||
| 573 | return .{ .active = session }; | |
| 574 | } | |
| 575 | ||
| 576 | /// `-[WCSession isReachable]`. Only ever called on an activated session, where | |
| 577 | /// the header says the property is meaningful. | |
| 578 | fn readIsReachable(session: *anyopaque) bool { | |
| 579 | if (!is_darwin) return false; | |
| 580 | ||
| 581 | const sel = objc.sel_registerName("isReachable") orelse return false; | |
| 582 | return objc.msgSendBool(session, sel); | |
| 583 | } | |
| 584 | ||
| 585 | /// `-[WCSession updateApplicationContext:error:]` — synchronous, `BOOL`, no | |
| 586 | /// completion handler. | |
| 587 | /// | |
| 588 | /// The `NSError` out-parameter is passed for real rather than null, which is | |
| 589 | /// where this differs from `bridge_mobile_misc.zig`'s `lockForConfiguration:`: | |
| 590 | /// there the BOOL was the whole answer, here the `WCErrorCode` is what tells | |
| 591 | /// "your payload is too large" apart from "there is no watch", and both the log | |
| 592 | /// line and the chosen `BridgeError` depend on it. | |
| 593 | fn pushApplicationContext( | |
| 594 | allocator: std.mem.Allocator, | |
| 595 | session: *anyopaque, | |
| 596 | context: std.json.Value, | |
| 597 | ) !void { | |
| 598 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 599 | ||
| 600 | const dictionary = try toFoundationDictionary(allocator, context); | |
| 601 | ||
| 602 | const sel_update = objc.sel_registerName("updateApplicationContext:error:") orelse | |
| 603 | return error.SelectorNotFound; | |
| 604 | ||
| 605 | var err_out: Id = null; | |
| 606 | const UpdateFn = *const fn (Id, objc.SEL, Id, ?*Id) callconv(.c) bool; | |
| 607 | const update_fn: UpdateFn = @ptrCast(&objc.objc_msgSend); | |
| 608 | if (update_fn(session, sel_update, dictionary, &err_out)) return; | |
| 609 | ||
| 610 | // A NO with no error object is not something the header allows, but a | |
| 611 | // nil-dereference here would be a crash where a refusal belongs. | |
| 612 | const ns_error = err_out orelse { | |
| 613 | std.log.warn("updateWatchContext: updateApplicationContext: returned NO with no NSError", .{}); | |
| 614 | return bridge_error.BridgeError.NativeCallFailed; | |
| 615 | }; | |
| 616 | ||
| 617 | const domain = readNSString(ns_error, "domain"); | |
| 618 | const code = readNSInteger(ns_error, "code"); | |
| 619 | // The one channel a `BridgeError` enum leaves for Swift's | |
| 620 | // `error.localizedDescription`, which the page used to receive verbatim. | |
| 621 | std.log.warn( | |
| 622 | "updateWatchContext: updateApplicationContext: failed — domain={s} code={d} description={s}", | |
| 623 | .{ | |
| 624 | domain orelse "(none)", | |
| 625 | code, | |
| 626 | readNSString(ns_error, "localizedDescription") orelse "(none)", | |
| 627 | }, | |
| 628 | ); | |
| 629 | return bridgeErrorForNSError(domain, code); | |
| 630 | } | |
| 631 | ||
| 632 | /// A parsed JSON object as the Foundation containers | |
| 633 | /// `updateApplicationContext:` accepts, via | |
| 634 | /// `+[NSJSONSerialization JSONObjectWithData:options:error:]` (autoreleased). | |
| 635 | /// | |
| 636 | /// The round trip through bytes is the honest route, and the same one Swift's | |
| 637 | /// hand-off path takes: NSString keys and values built this way keep embedded | |
| 638 | /// NULs that `stringWithUTF8String:` would truncate, and the containers it | |
| 639 | /// produces are the ones `updateApplicationContext:` wants. The one type it | |
| 640 | /// produces that is *not* a property list type is `NSNull`, from a JSON | |
| 641 | /// `null`; that is refused by `updateApplicationContext:` itself with | |
| 642 | /// `WCErrorCodePayloadUnsupportedTypes`, exactly as it refuses the `NSNull` | |
| 643 | /// WebKit hands the Swift shim. A nil back from here is the earlier failure — | |
| 644 | /// the context holds something JSON cannot carry — and is `InvalidParameter`, | |
| 645 | /// with the log naming it, since the error code alone cannot. | |
| 646 | fn toFoundationDictionary(allocator: std.mem.Allocator, value: std.json.Value) !*anyopaque { | |
| 647 | if (!is_darwin) return error.UnsupportedPlatform; | |
| 648 | ||
| 649 | var bytes: std.ArrayListUnmanaged(u8) = .empty; | |
| 650 | defer bytes.deinit(allocator); | |
| 651 | try serializeJsonValue(allocator, &bytes, value); | |
| 652 | ||
| 653 | const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound; | |
| 654 | const sel_data = objc.sel_registerName("dataWithBytes:length:") orelse return error.SelectorNotFound; | |
| 655 | const data = objc.msgSendId2(NSData, sel_data, bytes.items.ptr, @as(c_ulong, @intCast(bytes.items.len))); | |
| 656 | if (data == null) return error.NativeCallFailed; | |
| 657 | ||
| 658 | const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse return error.ClassNotFound; | |
| 659 | const sel_parse = objc.sel_registerName("JSONObjectWithData:options:error:") orelse | |
| 660 | return error.SelectorNotFound; | |
| 661 | const ParseFn = *const fn (Id, objc.SEL, Id, c_ulong, Id) callconv(.c) Id; | |
| 662 | const parse_fn: ParseFn = @ptrCast(&objc.objc_msgSend); | |
| 663 | return parse_fn(NSJSONSerialization, sel_parse, data, 0, null) orelse { | |
| 664 | std.log.warn("updateWatchContext: the context did not survive NSJSONSerialization; refusing", .{}); | |
| 665 | return bridge_error.BridgeError.InvalidParameter; | |
| 666 | }; | |
| 667 | } | |
| 668 | ||
| 669 | /// A zero-argument `NSString`-returning property, as bytes. Null for a nil | |
| 670 | /// string or a selector that will not register — both are "nothing to log", | |
| 671 | /// never a crash. | |
| 672 | fn readNSString(object: Id, comptime selector: [*:0]const u8) ?[]const u8 { | |
| 673 | if (!is_darwin) return null; | |
| 674 | ||
| 675 | const sel = objc.sel_registerName(selector) orelse return null; | |
| 676 | const value = objc.msgSendId(object, sel) orelse return null; | |
| 677 | const utf8 = objc.getNSStringUTF8(value) orelse return null; | |
| 678 | return std.mem.span(utf8); | |
| 679 | } | |
| 680 | ||
| 681 | /// A zero-argument `NSInteger`-returning property. | |
| 682 | fn readNSInteger(object: Id, comptime selector: [*:0]const u8) c_long { | |
| 683 | if (!is_darwin) return 0; | |
| 684 | ||
| 685 | const sel = objc.sel_registerName(selector) orelse return 0; | |
| 686 | const Fn = *const fn (Id, objc.SEL) callconv(.c) c_long; | |
| 687 | const func: Fn = @ptrCast(&objc.objc_msgSend); | |
| 688 | return func(object, sel); | |
| 689 | } | |
| 690 | ||
| 691 | // ============================================================================= | |
| 692 | // Tests — host-only. | |
| 693 | // | |
| 694 | // Everything that decides what the page sees is pure and pinned here: routing | |
| 695 | // in both directions, the omission of `sendToWatch`, the `context` field name, | |
| 696 | // the refusals Swift turned into a silent hang, the byte walk that carries the | |
| 697 | // nested context, the WCError mapping, and — the rule-4 regression test — the | |
| 698 | // object wrappers on both reply fragments. | |
| 699 | // | |
| 700 | // The one live Objective-C path a host can run is `resolveSession` on macOS, | |
| 701 | // where `objc_getClass("WCSession")` is null because `WCSession.h` is | |
| 702 | // `API_UNAVAILABLE(macos)`. That is asserted for real. Nothing here activates | |
| 703 | // a session or writes an application context: on a runner that did have a live | |
| 704 | // session, that would push a context to somebody's actual watch. | |
| 705 | // ============================================================================= | |
| 706 | ||
| 707 | const testing = std.testing; | |
| 708 | ||
| 709 | test "the declared actions are the ones the handler serves" { | |
| 710 | try testing.expectEqual(@as(usize, 2), capability_actions.len); | |
| 711 | try testing.expectEqualStrings(A.update_watch_context, capability_actions[0].name); | |
| 712 | try testing.expectEqualStrings(A.is_watch_reachable, capability_actions[1].name); | |
| 713 | ||
| 714 | for (capability_actions) |decl| { | |
| 715 | try testing.expectEqual(capabilities.Reply.result, decl.reply); | |
| 716 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); | |
| 717 | // `.reason` is only meaningful on an `.unavailable` row; a stray one | |
| 718 | // here would be shown to an app about an action that works. | |
| 719 | try testing.expect(decl.reason == null); | |
| 720 | } | |
| 721 | } | |
| 722 | ||
| 723 | test "the action names match the Swift case labels exactly" { | |
| 724 | // The conformance ratchet compares these against the `case "…":` labels in | |
| 725 | // `CraftApp.swift` in both directions; a prettier spelling would read as | |
| 726 | // Zig serving an action the spec does not have. | |
| 727 | try testing.expectEqualStrings("updateWatchContext", A.update_watch_context); | |
| 728 | try testing.expectEqualStrings("isWatchReachable", A.is_watch_reachable); | |
| 729 | } | |
| 730 | ||
| 731 | test "every declared action is one the dispatcher routes" { | |
| 732 | for (capability_actions) |decl| { | |
| 733 | if (routeFor(decl.name) == null) { | |
| 734 | std.debug.print("declared action '{s}' does not route\n", .{decl.name}); | |
| 735 | return error.DeclaredActionDoesNotRoute; | |
| 736 | } | |
| 737 | } | |
| 738 | } | |
| 739 | ||
| 740 | test "every route the dispatcher has is a declared action" { | |
| 741 | // The other direction: a route with a handler and no declaration is an | |
| 742 | // action the page can call and the manifest denies exists. Each | |
| 743 | // declaration must claim a *distinct* route — counting alone would let two | |
| 744 | // rows share one route while another went undeclared. | |
| 745 | var claimed = std.mem.zeroes([std.enums.values(Route).len]bool); | |
| 746 | for (capability_actions) |decl| { | |
| 747 | const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute; | |
| 748 | const slot = @backingInt(route); | |
| 749 | if (claimed[slot]) { | |
| 750 | std.debug.print("two declarations route to {s}\n", .{@tagName(route)}); | |
| 751 | return error.TwoDeclarationsShareARoute; | |
| 752 | } | |
| 753 | claimed[slot] = true; | |
| 754 | } | |
| 755 | for (claimed, 0..) |taken, slot| { | |
| 756 | if (!taken) { | |
| 757 | std.debug.print( | |
| 758 | "route {s} has a handler but no capability_actions row\n", | |
| 759 | .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))}, | |
| 760 | ); | |
| 761 | return error.RouteNotDeclared; | |
| 762 | } | |
| 763 | } | |
| 764 | } | |
| 765 | ||
| 766 | test "sendToWatch is left to the Swift shim, not claimed and not refused" { | |
| 767 | // The deliberate omission, asserted so it cannot be "fixed" by accident. | |
| 768 | // | |
| 769 | // `ios_dispatch`'s chain treats UnknownAction as "not mine, ask the next" | |
| 770 | // and any other error as final. So the *only* spelling of "let the shim | |
| 771 | // keep serving this" is: absent from `A`, absent from `routeFor`, and | |
| 772 | // UnknownAction out of `handleMessage`. An `.unavailable` declaration | |
| 773 | // would dispatch and refuse, stealing an action the shim answers correctly | |
| 774 | // today — including the error path, which `ios_async` cannot yet deliver. | |
| 775 | var bridge = WatchBridge.init(testing.allocator); | |
| 776 | defer bridge.deinit(); | |
| 777 | ||
| 778 | try testing.expect(routeFor("sendToWatch") == null); | |
| 779 | try testing.expectError( | |
| 780 | bridge_error.BridgeError.UnknownAction, | |
| 781 | bridge.handleMessage("sendToWatch", "{\"message\":{\"action\":\"ping\"}}"), | |
| 782 | ); | |
| 783 | ||
| 784 | for (capability_actions) |decl| { | |
| 785 | try testing.expect(!std.mem.eql(u8, decl.name, "sendToWatch")); | |
| 786 | } | |
| 787 | } | |
| 788 | ||
| 789 | test "an action the namespace does not serve is reported, not ignored" { | |
| 790 | var bridge = WatchBridge.init(testing.allocator); | |
| 791 | defer bridge.deinit(); | |
| 792 | ||
| 793 | try testing.expectError( | |
| 794 | bridge_error.BridgeError.UnknownAction, | |
| 795 | bridge.handleMessage("noSuchAction", "{}"), | |
| 796 | ); | |
| 797 | // Near misses — casing is how a real typo arrives. | |
| 798 | try testing.expectError( | |
| 799 | bridge_error.BridgeError.UnknownAction, | |
| 800 | bridge.handleMessage("iswatchreachable", "{}"), | |
| 801 | ); | |
| 802 | try testing.expectError( | |
| 803 | bridge_error.BridgeError.UnknownAction, | |
| 804 | bridge.handleMessage("updatewatchcontext", "{\"context\":{}}"), | |
| 805 | ); | |
| 806 | // The JS surface method names, which are not action names. | |
| 807 | try testing.expectError( | |
| 808 | bridge_error.BridgeError.UnknownAction, | |
| 809 | bridge.handleMessage("isReachable", "{}"), | |
| 810 | ); | |
| 811 | try testing.expectError( | |
| 812 | bridge_error.BridgeError.UnknownAction, | |
| 813 | bridge.handleMessage("updateContext", "{\"context\":{}}"), | |
| 814 | ); | |
| 815 | // The Swift *helper* name, which is not a dispatcher case. | |
| 816 | try testing.expectError( | |
| 817 | bridge_error.BridgeError.UnknownAction, | |
| 818 | bridge.handleMessage("sendMessageToWatch", "{}"), | |
| 819 | ); | |
| 820 | } | |
| 821 | ||
| 822 | test "a malformed payload is reported as bad JSON, not as a missing field" { | |
| 823 | var bridge = WatchBridge.init(testing.allocator); | |
| 824 | defer bridge.deinit(); | |
| 825 | ||
| 826 | try testing.expectError( | |
| 827 | bridge_error.BridgeError.InvalidJSON, | |
| 828 | bridge.handleMessage(A.update_watch_context, "{not json"), | |
| 829 | ); | |
| 830 | } | |
| 831 | ||
| 832 | fn expectContextError(json: []const u8, expected: anyerror) !void { | |
| 833 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 834 | defer parsed.deinit(); | |
| 835 | try testing.expectError(expected, parseContext(parsed.value)); | |
| 836 | } | |
| 837 | ||
| 838 | test "the field name the page sends is the one that is read" { | |
| 839 | // `{action:'updateWatchContext', context: context, callbackId: id}` in the | |
| 840 | // injected JS; the shim reads `body["context"]`. A rename on either side of | |
| 841 | // the migration would make the two handlers read different payloads. | |
| 842 | var parsed = try std.json.parseFromSlice( | |
| 843 | std.json.Value, | |
| 844 | testing.allocator, | |
| 845 | "{\"context\":{\"status\":\"active\"},\"callbackId\":\"cb_7\"}", | |
| 846 | .{}, | |
| 847 | ); | |
| 848 | defer parsed.deinit(); | |
| 849 | ||
| 850 | const context = try parseContext(parsed.value); | |
| 851 | try testing.expectEqualStrings("active", context.object.get("status").?.string); | |
| 852 | ||
| 853 | // The plausible wrong names are *absences*, not aliases — a payload | |
| 854 | // carrying only them must read as missing, or a handler bound to the wrong | |
| 855 | // name would pass this suite. | |
| 856 | try expectContextError("{\"applicationContext\":{\"a\":1}}", bridge_error.BridgeError.MissingData); | |
| 857 | try expectContextError("{\"data\":{\"a\":1}}", bridge_error.BridgeError.MissingData); | |
| 858 | try expectContextError("{\"message\":{\"a\":1}}", bridge_error.BridgeError.MissingData); | |
| 859 | } | |
| 860 | ||
| 861 | test "a missing context is refused rather than defaulted" { | |
| 862 | // Swift's `if let context = body["context"] as? [String: Any]` fails here | |
| 863 | // and replies nothing at all — the legacy promise has no timeout, so that | |
| 864 | // is a hang forever. An error is the deliberate divergence; defaulting to | |
| 865 | // `{}` would be worse, because an empty dictionary is a *meaningful* | |
| 866 | // context that clears whatever the watch was showing. | |
| 867 | var bridge = WatchBridge.init(testing.allocator); | |
| 868 | defer bridge.deinit(); | |
| 869 | ||
| 870 | try testing.expectError( | |
| 871 | bridge_error.BridgeError.MissingData, | |
| 872 | bridge.handleMessage(A.update_watch_context, "{}"), | |
| 873 | ); | |
| 874 | } | |
| 875 | ||
| 876 | test "a context that is not an object is refused, not coerced" { | |
| 877 | try expectContextError("{\"context\":\"active\"}", bridge_error.BridgeError.InvalidParameter); | |
| 878 | try expectContextError("{\"context\":7}", bridge_error.BridgeError.InvalidParameter); | |
| 879 | try expectContextError("{\"context\":null}", bridge_error.BridgeError.InvalidParameter); | |
| 880 | try expectContextError("{\"context\":true}", bridge_error.BridgeError.InvalidParameter); | |
| 881 | // An array is the interesting one: it is a valid JSON *container* and | |
| 882 | // still not something `updateApplicationContext:` accepts. | |
| 883 | try expectContextError("{\"context\":[1,2]}", bridge_error.BridgeError.InvalidParameter); | |
| 884 | } | |
| 885 | ||
| 886 | test "an empty context is accepted, because the header says it is meaningful" { | |
| 887 | // `WCSession.h`: "If there is no app context, it should be updated with an | |
| 888 | // empty dictionary." Refusing `{}` would deny a page the documented way to | |
| 889 | // clear the context. | |
| 890 | var parsed = try std.json.parseFromSlice( | |
| 891 | std.json.Value, | |
| 892 | testing.allocator, | |
| 893 | "{\"context\":{}}", | |
| 894 | .{}, | |
| 895 | ); | |
| 896 | defer parsed.deinit(); | |
| 897 | ||
| 898 | const context = try parseContext(parsed.value); | |
| 899 | try testing.expectEqual(@as(usize, 0), context.object.count()); | |
| 900 | } | |
| 901 | ||
| 902 | test "a payload that is not an object is bad JSON, not a missing field" { | |
| 903 | try expectContextError("[]", bridge_error.BridgeError.InvalidJSON); | |
| 904 | try expectContextError("\"context\"", bridge_error.BridgeError.InvalidJSON); | |
| 905 | try expectContextError("null", bridge_error.BridgeError.InvalidJSON); | |
| 906 | } | |
| 907 | ||
| 908 | test "the replies are the objects Swift resolves, not bare fragments" { | |
| 909 | // The rule-4 regression test, and the reason this module is not like its | |
| 910 | // siblings. Swift resolves `["reachable": reachable]` and | |
| 911 | // `["updated": true]`; `.fragmentsAllowed` permits a bare value but never | |
| 912 | // unwraps a dictionary into one. | |
| 913 | // | |
| 914 | // `craft.d.ts` types these `Promise<{reachable: boolean}>` and | |
| 915 | // `Promise<{updated: boolean}>`, and `test-bridges.html` renders | |
| 916 | // `JSON.stringify(result)` — so a bare `false` here would print | |
| 917 | // "Watch reachable: false" instead of "Watch reachable: {"reachable":false}" | |
| 918 | // and make `result.reachable` undefined in every real app. | |
| 919 | try testing.expectEqualStrings("{\"reachable\":true}", reachableFragment(true)); | |
| 920 | try testing.expectEqualStrings("{\"reachable\":false}", reachableFragment(false)); | |
| 921 | try testing.expectEqualStrings("{\"updated\":true}", updated_true); | |
| 922 | ||
| 923 | // And they are parseable as the objects they claim to be, with the field | |
| 924 | // names the page reads. | |
| 925 | for ([_][]const u8{ reachable_true, reachable_false }) |fragment| { | |
| 926 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, fragment, .{}); | |
| 927 | defer parsed.deinit(); | |
| 928 | try testing.expect(parsed.value.object.get("reachable").? == .bool); | |
| 929 | } | |
| 930 | var updated = try std.json.parseFromSlice(std.json.Value, testing.allocator, updated_true, .{}); | |
| 931 | defer updated.deinit(); | |
| 932 | try testing.expectEqual(true, updated.value.object.get("updated").?.bool); | |
| 933 | } | |
| 934 | ||
| 935 | test "there is no updated:false reply, because Swift rejects instead" { | |
| 936 | // `updateWatchContext`'s failure path is `rejectCallback`, not | |
| 937 | // `resolveCallback(["updated": false])`. A `false` fragment existing at all | |
| 938 | // would be an invitation to resolve one, which is fabricated success: the | |
| 939 | // page would see a settled promise for a context that never synced. | |
| 940 | try testing.expect(std.mem.indexOf(u8, updated_true, "false") == null); | |
| 941 | } | |
| 942 | ||
| 943 | fn serializeToOwned(value: std.json.Value) ![]u8 { | |
| 944 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 945 | errdefer out.deinit(testing.allocator); | |
| 946 | try serializeJsonValue(testing.allocator, &out, value); | |
| 947 | return out.toOwnedSlice(testing.allocator); | |
| 948 | } | |
| 949 | ||
| 950 | fn expectRoundTrip(source: []const u8, expected: []const u8) !void { | |
| 951 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, source, .{}); | |
| 952 | defer parsed.deinit(); | |
| 953 | ||
| 954 | const bytes = try serializeToOwned(parsed.value); | |
| 955 | defer testing.allocator.free(bytes); | |
| 956 | try testing.expectEqualStrings(expected, bytes); | |
| 957 | } | |
| 958 | ||
| 959 | test "the context is carried whole, nested values included" { | |
| 960 | // The dropped-field bug this migration exists to remove. Swift hands the | |
| 961 | // context to Foundation with its nesting intact; anything less here would | |
| 962 | // sync a context the page did not ask for and still reply | |
| 963 | // `{"updated":true}`. | |
| 964 | try expectRoundTrip( | |
| 965 | "{\"lastUpdate\":1700000000000,\"status\":\"active\"}", | |
| 966 | "{\"lastUpdate\":1700000000000,\"status\":\"active\"}", | |
| 967 | ); | |
| 968 | try expectRoundTrip( | |
| 969 | "{\"a\":{\"b\":[1,true,null,\"c\"]}}", | |
| 970 | "{\"a\":{\"b\":[1,true,null,\"c\"]}}", | |
| 971 | ); | |
| 972 | try expectRoundTrip("{}", "{}"); | |
| 973 | } | |
| 974 | ||
| 975 | test "control bytes in keys and values are escaped, not emitted raw" { | |
| 976 | // An unescaped control byte makes the bytes invalid JSON, which Foundation | |
| 977 | // refuses — turning a legal context into a refusal. NUL is the one | |
| 978 | // `bridge_handoff.zig`'s older copy of this walk misses. | |
| 979 | try expectRoundTrip( | |
| 980 | "{\"a\\u0000b\":\"c\\u0000d\"}", | |
| 981 | "{\"a\\u0000b\":\"c\\u0000d\"}", | |
| 982 | ); | |
| 983 | try expectRoundTrip("{\"k\":\"line\\nbreak\"}", "{\"k\":\"line\\nbreak\"}"); | |
| 984 | try expectRoundTrip("{\"k\":\"quote\\\"and\\\\slash\"}", "{\"k\":\"quote\\\"and\\\\slash\"}"); | |
| 985 | } | |
| 986 | ||
| 987 | test "an embedded NUL survives instead of being refused" { | |
| 988 | // The siblings refuse NULs because their route is `stringWithUTF8String:`, | |
| 989 | // which truncates. This one is length-based through | |
| 990 | // `NSJSONSerialization`, so there is nothing to refuse — and refusing | |
| 991 | // anyway would drop a payload the handler carries perfectly well. | |
| 992 | var parsed = try std.json.parseFromSlice( | |
| 993 | std.json.Value, | |
| 994 | testing.allocator, | |
| 995 | "{\"context\":{\"k\":\"a\\u0000b\"}}", | |
| 996 | .{}, | |
| 997 | ); | |
| 998 | defer parsed.deinit(); | |
| 999 | ||
| 1000 | const context = try parseContext(parsed.value); | |
| 1001 | const value = context.object.get("k").?.string; | |
| 1002 | try testing.expectEqual(@as(usize, 3), value.len); | |
| 1003 | try testing.expectEqual(@as(u8, 0), value[1]); | |
| 1004 | } | |
| 1005 | ||
| 1006 | test "a non-finite number is refused before Foundation sees it" { | |
| 1007 | // `{d}` would render `inf`, which is not JSON; Foundation would answer nil | |
| 1008 | // and the cause would be invisible. | |
| 1009 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 1010 | defer out.deinit(testing.allocator); | |
| 1011 | ||
| 1012 | try testing.expectError( | |
| 1013 | bridge_error.BridgeError.InvalidParameter, | |
| 1014 | serializeJsonValue(testing.allocator, &out, .{ .float = std.math.inf(f64) }), | |
| 1015 | ); | |
| 1016 | out.clearRetainingCapacity(); | |
| 1017 | try testing.expectError( | |
| 1018 | bridge_error.BridgeError.InvalidParameter, | |
| 1019 | serializeJsonValue(testing.allocator, &out, .{ .float = std.math.nan(f64) }), | |
| 1020 | ); | |
| 1021 | } | |
| 1022 | ||
| 1023 | test "a large float keeps its digits instead of overflowing the buffer" { | |
| 1024 | // 1e300 is 301 digits in decimal notation; a [64]u8 would turn a legal | |
| 1025 | // JSON number into a NoSpaceLeft refusal. | |
| 1026 | const bytes = try serializeToOwned(.{ .float = 1e300 }); | |
| 1027 | defer testing.allocator.free(bytes); | |
| 1028 | try testing.expect(bytes.len > 300); | |
| 1029 | try testing.expectEqual(@as(u8, '1'), bytes[0]); | |
| 1030 | } | |
| 1031 | ||
| 1032 | test "a number too big for i64 or f64 is carried verbatim, not rounded or dropped" { | |
| 1033 | // The `.number_string` arm, which the sibling copy of this walk dismisses | |
| 1034 | // as unreachable. It is not: `std.json` falls back to it for an integer | |
| 1035 | // literal that overflows `i64` and for a float literal that parses | |
| 1036 | // non-finite, both under the default options this module uses. Pinned | |
| 1037 | // because the two wrong answers are silent — `{d}` on a rounded `f64` | |
| 1038 | // would sync a *different* number than the page sent and still reply | |
| 1039 | // `{"updated":true}`, and refusing would drop a field Swift carries. | |
| 1040 | var parsed = try std.json.parseFromSlice( | |
| 1041 | std.json.Value, | |
| 1042 | testing.allocator, | |
| 1043 | "{\"big\":99999999999999999999,\"huge\":1e400}", | |
| 1044 | .{}, | |
| 1045 | ); | |
| 1046 | defer parsed.deinit(); | |
| 1047 | try testing.expect(parsed.value.object.get("big").? == .number_string); | |
| 1048 | try testing.expect(parsed.value.object.get("huge").? == .number_string); | |
| 1049 | ||
| 1050 | try expectRoundTrip( | |
| 1051 | "{\"big\":99999999999999999999,\"huge\":1e400}", | |
| 1052 | "{\"big\":99999999999999999999,\"huge\":1e400}", | |
| 1053 | ); | |
| 1054 | } | |
| 1055 | ||
| 1056 | test "WCError codes map to the nearest code the page can act on" { | |
| 1057 | const E = bridge_error.BridgeError; | |
| 1058 | ||
| 1059 | // The page's payload is the thing to change. | |
| 1060 | try testing.expectEqual(E.InvalidParameter, bridgeErrorForNSError(wc_error_domain, wc_invalid_parameter)); | |
| 1061 | try testing.expectEqual(E.InvalidParameter, bridgeErrorForNSError(wc_error_domain, wc_payload_too_large)); | |
| 1062 | try testing.expectEqual(E.InvalidParameter, bridgeErrorForNSError(wc_error_domain, wc_payload_unsupported_types)); | |
| 1063 | ||
| 1064 | // There is no watch on the other end. | |
| 1065 | try testing.expectEqual(E.NotFound, bridgeErrorForNSError(wc_error_domain, wc_device_not_paired)); | |
| 1066 | try testing.expectEqual(E.NotFound, bridgeErrorForNSError(wc_error_domain, wc_watch_app_not_installed)); | |
| 1067 | ||
| 1068 | // Session state and everything unnamed. | |
| 1069 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError(wc_error_domain, 7003)); | |
| 1070 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError(wc_error_domain, 7004)); | |
| 1071 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError(wc_error_domain, 7007)); | |
| 1072 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError(wc_error_domain, 0)); | |
| 1073 | } | |
| 1074 | ||
| 1075 | test "a code from another domain is not read as a WCError" { | |
| 1076 | // `NSError` codes are domain-scoped. `NSCocoaErrorDomain` 7009 is not | |
| 1077 | // PayloadTooLarge, and blaming the page's payload for it would send whoever | |
| 1078 | // debugs it to the wrong side of the bridge. | |
| 1079 | const E = bridge_error.BridgeError; | |
| 1080 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError("NSCocoaErrorDomain", wc_payload_too_large)); | |
| 1081 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError("NSPOSIXErrorDomain", wc_device_not_paired)); | |
| 1082 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError(null, wc_invalid_parameter)); | |
| 1083 | // A prefix match would be a silent widening; the comparison is exact. | |
| 1084 | try testing.expectEqual(E.NativeCallFailed, bridgeErrorForNSError("WCErrorDomainExtra", wc_invalid_parameter)); | |
| 1085 | } | |
| 1086 | ||
| 1087 | test "validation outruns Objective-C on every platform" { | |
| 1088 | // The same refusals with and without a runtime behind them: a payload the | |
| 1089 | // handler must refuse is refused identically on Linux and macOS, which is | |
| 1090 | // what makes the parser tests above binding for the device build too. | |
| 1091 | var bridge = WatchBridge.init(testing.allocator); | |
| 1092 | defer bridge.deinit(); | |
| 1093 | ||
| 1094 | try testing.expectError( | |
| 1095 | bridge_error.BridgeError.MissingData, | |
| 1096 | bridge.handleMessage(A.update_watch_context, "{}"), | |
| 1097 | ); | |
| 1098 | try testing.expectError( | |
| 1099 | bridge_error.BridgeError.InvalidParameter, | |
| 1100 | bridge.handleMessage(A.update_watch_context, "{\"context\":\"active\"}"), | |
| 1101 | ); | |
| 1102 | try testing.expectError( | |
| 1103 | bridge_error.BridgeError.InvalidJSON, | |
| 1104 | bridge.handleMessage(A.update_watch_context, "[]"), | |
| 1105 | ); | |
| 1106 | } | |
| 1107 | ||
| 1108 | test "off Darwin the handlers refuse rather than fake an answer" { | |
| 1109 | if (is_darwin) return error.SkipZigTest; | |
| 1110 | ||
| 1111 | var bridge = WatchBridge.init(testing.allocator); | |
| 1112 | defer bridge.deinit(); | |
| 1113 | ||
| 1114 | // A valid payload, so the refusal is the platform's and not the parser's. | |
| 1115 | try testing.expectError( | |
| 1116 | error.UnsupportedPlatform, | |
| 1117 | bridge.handleMessage(A.update_watch_context, "{\"context\":{\"status\":\"active\"}}"), | |
| 1118 | ); | |
| 1119 | // Notably *not* `{"reachable":false}`: off Darwin there is no WCSession | |
| 1120 | // question to answer, and answering it anyway would be a claim nothing | |
| 1121 | // checked. | |
| 1122 | try testing.expectError( | |
| 1123 | error.UnsupportedPlatform, | |
| 1124 | bridge.handleMessage(A.is_watch_reachable, "{}"), | |
| 1125 | ); | |
| 1126 | try testing.expectError(error.UnsupportedPlatform, resolveSession()); | |
| 1127 | } | |
| 1128 | ||
| 1129 | test "on a macOS host WatchConnectivity is absent, and that is a real answer" { | |
| 1130 | if (!is_darwin) return error.SkipZigTest; | |
| 1131 | ||
| 1132 | // `WCSession.h` is `API_AVAILABLE(ios, macCatalyst) API_UNAVAILABLE(macos)`, | |
| 1133 | // so `objc_getClass` returns null here. This is the one live Objective-C | |
| 1134 | // path a host can exercise, and the honest answer is exactly Swift's: | |
| 1135 | // no session, no reachable watch. | |
| 1136 | // | |
| 1137 | // If a runner ever *does* resolve the class — a Catalyst host — pinning | |
| 1138 | // `.framework_absent` would be wrong to force, so any absence is accepted. | |
| 1139 | // What must never happen is `.active` on a machine with no paired watch. | |
| 1140 | // | |
| 1141 | // The second call is the load-bearing half, and the reason this is not a | |
| 1142 | // tautology: `resolveSession` promises to be *read-only*. It reads | |
| 1143 | // `isSupported`, `defaultSession` and `activationState` and stops — it | |
| 1144 | // never calls `activate` or `setDelegate:`, because activating without a | |
| 1145 | // `WCSessionDelegate` is undefined and would stomp the delegate feeding | |
| 1146 | // the page's `craftWatchMessage` events. An implementation that activated | |
| 1147 | // would change its own answer between these two calls, on the very host | |
| 1148 | // where nothing else can catch it. | |
| 1149 | const first = try resolveSession(); | |
| 1150 | switch (first) { | |
| 1151 | .absent => {}, | |
| 1152 | .active => { | |
| 1153 | // A live activated session on the test host means the runner is an | |
| 1154 | // app with WatchConnectivity set up. Updating its context would | |
| 1155 | // push to somebody's actual watch, so stop rather than pretend. | |
| 1156 | return error.SkipZigTest; | |
| 1157 | }, | |
| 1158 | } | |
| 1159 | ||
| 1160 | const second = try resolveSession(); | |
| 1161 | switch (second) { | |
| 1162 | .absent => |why| try testing.expectEqual(first.absent, why), | |
| 1163 | .active => return error.ResolveSessionActivatedTheSession, | |
| 1164 | } | |
| 1165 | } | |
| 1166 | ||
| 1167 | test "an absent session refuses the update instead of reporting one" { | |
| 1168 | if (!is_darwin) return error.SkipZigTest; | |
| 1169 | ||
| 1170 | // The rule-1 case. There is no WCSession on this host, so nothing can have | |
| 1171 | // been synced — `{"updated":true}` would be a fabricated success. The | |
| 1172 | // refusal must come from the session check and not from the parser, so the | |
| 1173 | // payload is a valid one. | |
| 1174 | switch (try resolveSession()) { | |
| 1175 | .active => return error.SkipZigTest, | |
| 1176 | .absent => {}, | |
| 1177 | } | |
| 1178 | ||
| 1179 | var bridge = WatchBridge.init(testing.allocator); | |
| 1180 | defer bridge.deinit(); | |
| 1181 | ||
| 1182 | try testing.expectError( | |
| 1183 | bridge_error.BridgeError.NotFound, | |
| 1184 | bridge.handleMessage(A.update_watch_context, "{\"context\":{\"status\":\"active\"}}"), | |
| 1185 | ); | |
| 1186 | } | |
| 1187 | ||
| 1188 | test "isWatchReachable answers false instead of erroring when there is no session" { | |
| 1189 | if (!is_darwin) return error.SkipZigTest; | |
| 1190 | ||
| 1191 | // The property that makes this action `.reply = .result` and never a | |
| 1192 | // rejection: Swift's `wcSession?.isReachable ?? false` always resolves, and | |
| 1193 | // the promise it resolves has no timeout. A handler that errored here would | |
| 1194 | // reject a call the page expects to settle with `{"reachable":false}`. | |
| 1195 | // | |
| 1196 | // This is the one test that runs a handler all the way to | |
| 1197 | // `sendResultToJS`, so it logs one "failed to send bridge result to JS" | |
| 1198 | // warning: there is no webview in a test runner. That warning is the | |
| 1199 | // evidence the reply was attempted — the alternative, asserting only that | |
| 1200 | // no error came back, would also pass for a handler that silently returned | |
| 1201 | // without replying at all. | |
| 1202 | switch (try resolveSession()) { | |
| 1203 | .active => return error.SkipZigTest, | |
| 1204 | .absent => {}, | |
| 1205 | } | |
| 1206 | ||
| 1207 | var bridge = WatchBridge.init(testing.allocator); | |
| 1208 | defer bridge.deinit(); | |
| 1209 | ||
| 1210 | try bridge.handleMessage(A.is_watch_reachable, "{}"); | |
| 1211 | // The payload is ignored, as Swift ignores the body — a page posting extra | |
| 1212 | // fields must not turn a question into a refusal. | |
| 1213 | try bridge.handleMessage(A.is_watch_reachable, "{\"callbackId\":\"cb_7\"}"); | |
| 1214 | } | |
| @@ -59,6 +59,15 @@ const Slot = struct { | ||
| 59 | 59 | /// What the completion resolved. Written on the firing thread, read on |
| 60 | 60 | /// the main thread after the dispatch_async_f hop. |
| 61 | 61 | granted: bool = false, |
| 62 | /// A module-shaped reply, for completions this file cannot shape itself. | |
| 63 | /// Owned by the slot; freed on delivery. Null means use `granted`. | |
| 64 | json: ?[]u8 = null, | |
| 65 | /// Set when the reply could not be produced at all. Distinct from a null | |
| 66 | /// `json`, which legitimately means "this was a BOOL completion" — without | |
| 67 | /// the flag, a failure falls through to the BOOL wording and resolves | |
| 68 | /// `"denied"` for a caller expecting an array. That is a wrong answer | |
| 69 | /// reported as success, and the page's catch never runs. | |
| 70 | failed: bool = false, | |
| 62 | 71 | }; |
| 63 | 72 | |
| 64 | 73 | var slots: [max_in_flight]Slot = @splat(.{}); |
| @@ -219,6 +228,8 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 219 | 228 | var action_len: usize = 0; |
| 220 | 229 | var request_id: i64 = -1; |
| 221 | 230 | var granted = false; |
| 231 | var json: ?[]u8 = null; | |
| 232 | var failed = false; | |
| 222 | 233 | { |
| 223 | 234 | slots_mutex.lock(); |
| 224 | 235 | defer slots_mutex.unlock(); |
| @@ -228,9 +239,14 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 228 | 239 | @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]); |
| 229 | 240 | request_id = slot.request_id; |
| 230 | 241 | granted = slot.granted; |
| 242 | json = slot.json; | |
| 243 | failed = slot.failed; | |
| 244 | slot.json = null; | |
| 245 | slot.failed = false; | |
| 231 | 246 | slot.in_use = false; |
| 232 | 247 | slot.generation +%= 1; |
| 233 | 248 | } |
| 249 | defer if (json) |owned| std.heap.c_allocator.free(owned); | |
| 234 | 250 | |
| 235 | 251 | const action = action_buf[0..action_len]; |
| 236 | 252 | |
| @@ -239,13 +255,72 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void { | ||
| 239 | 255 | request_context.push(if (request_id < 0) null else @intCast(request_id)); |
| 240 | 256 | defer request_context.pop(); |
| 241 | 257 | |
| 258 | if (failed) { | |
| 259 | bridge_error.sendErrorToJS( | |
| 260 | std.heap.c_allocator, | |
| 261 | action, | |
| 262 | bridge_error.BridgeError.NativeCallFailed, | |
| 263 | ); | |
| 264 | return; | |
| 265 | } | |
| 266 | ||
| 242 | 267 | bridge_error.sendResultToJS( |
| 243 | 268 | std.heap.c_allocator, |
| 244 | 269 | action, |
| 245 | if (granted) "\"granted\"" else "\"denied\"", | |
| 270 | json orelse if (granted) "\"granted\"" else "\"denied\"", | |
| 246 | 271 | ); |
| 247 | 272 | } |
| 248 | 273 | |
| 274 | /// Deliver a reply for a ticket whose completion this file could not shape. | |
| 275 | /// | |
| 276 | /// The pre-built pool blocks cover `void (^)(BOOL)` and `void (^)(BOOL, NSError *)`, | |
| 277 | /// which is most permission-style APIs and nothing else. A completion handing | |
| 278 | /// back an array — `getPendingNotificationRequestsWithCompletionHandler:` — has | |
| 279 | /// to be shaped by the module that knows how to serialise it. | |
| 280 | /// | |
| 281 | /// So a module may build its own block, and still get the two things that are | |
| 282 | /// genuinely hard: the request id captured at dispatch (the block reads its | |
| 283 | /// ticket from wherever it stashed it) and the hop to the main queue. It hands | |
| 284 | /// the finished JSON here and this does the rest. | |
| 285 | /// | |
| 286 | /// The JSON is copied, because the caller's buffer is usually a stack local in | |
| 287 | /// a completion that returns immediately after this call. | |
| 288 | pub fn deliverJson(ticket: Ticket, json: []const u8) void { | |
| 289 | const allocator = std.heap.c_allocator; | |
| 290 | ||
| 291 | slots_mutex.lock(); | |
| 292 | const valid = slots[ticket.index].in_use and slots[ticket.index].generation == ticket.generation; | |
| 293 | if (valid) { | |
| 294 | // Stash the payload on the slot so the main-queue hop carries nothing | |
| 295 | // but an index, same as the BOOL path. | |
| 296 | if (allocator.dupe(u8, json)) |copy| { | |
| 297 | slots[ticket.index].json = copy; | |
| 298 | } else |_| { | |
| 299 | slots[ticket.index].failed = true; | |
| 300 | } | |
| 301 | } | |
| 302 | slots_mutex.unlock(); | |
| 303 | ||
| 304 | if (!valid) return; | |
| 305 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain); | |
| 306 | } | |
| 307 | ||
| 308 | /// Reply to a captured call with an error rather than a result. | |
| 309 | /// | |
| 310 | /// A module's own completion can fail to shape its answer — a nil object where | |
| 311 | /// one was promised, a serialisation that ran out of room. Without this the | |
| 312 | /// only options were silence (the page waits out its timeout) or a fabricated | |
| 313 | /// result. The slot is released either way. | |
| 314 | pub fn deliverError(ticket: Ticket) void { | |
| 315 | slots_mutex.lock(); | |
| 316 | const valid = slots[ticket.index].in_use and slots[ticket.index].generation == ticket.generation; | |
| 317 | if (valid) slots[ticket.index].failed = true; | |
| 318 | slots_mutex.unlock(); | |
| 319 | ||
| 320 | if (!valid) return; | |
| 321 | dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, ticket.index) + 1), deliverOnMain); | |
| 322 | } | |
| 323 | ||
| 249 | 324 | const testing = std.testing; |
| 250 | 325 | |
| 251 | 326 | test "acquire captures the current request id, release makes the slot reusable" { |
| @@ -293,3 +368,60 @@ test "an action longer than the slot's storage is refused at acquire" { | ||
| 293 | 368 | const long: [65]u8 = @splat('a'); |
| 294 | 369 | try testing.expect(acquire(&long) == null); |
| 295 | 370 | } |
| 371 | ||
| 372 | test "a module-shaped reply is carried and freed, and beats the granted flag" { | |
| 373 | request_context.push(5); | |
| 374 | const ticket = acquire("getPendingNotifications") orelse return error.PoolUnexpectedlyFull; | |
| 375 | request_context.pop(); | |
| 376 | ||
| 377 | deliverJson(ticket, "[{\"id\":\"a\"}]"); | |
| 378 | ||
| 379 | // The payload is on the slot until the main-queue hop drains it. Reading it | |
| 380 | // here is the only way to assert the copy happened without a run loop. | |
| 381 | slots_mutex.lock(); | |
| 382 | defer slots_mutex.unlock(); | |
| 383 | try testing.expect(slots[ticket.index].json != null); | |
| 384 | try testing.expectEqualStrings("[{\"id\":\"a\"}]", slots[ticket.index].json.?); | |
| 385 | ||
| 386 | // Drain by hand so the test leaves no allocation behind: the dispatch hop | |
| 387 | // will not run without a live main queue. | |
| 388 | std.heap.c_allocator.free(slots[ticket.index].json.?); | |
| 389 | slots[ticket.index].json = null; | |
| 390 | slots[ticket.index].in_use = false; | |
| 391 | slots[ticket.index].generation +%= 1; | |
| 392 | } | |
| 393 | ||
| 394 | test "a stale ticket cannot inject a payload into a re-leased slot" { | |
| 395 | const first = acquire("a") orelse return error.PoolUnexpectedlyFull; | |
| 396 | abandon(first); | |
| 397 | const second = acquire("b") orelse return error.PoolUnexpectedlyFull; | |
| 398 | defer abandon(second); | |
| 399 | ||
| 400 | deliverJson(first, "\"stale\""); | |
| 401 | ||
| 402 | slots_mutex.lock(); | |
| 403 | defer slots_mutex.unlock(); | |
| 404 | try testing.expect(slots[second.index].json == null); | |
| 405 | } | |
| 406 | ||
| 407 | test "a reply that could not be shaped is flagged, not silently downgraded" { | |
| 408 | // Without the flag, a failure falls through to the BOOL wording and the | |
| 409 | // caller gets the string "denied" where it expected an array — a wrong | |
| 410 | // answer delivered as success. The flag is what makes deliverOnMain send | |
| 411 | // an error instead. | |
| 412 | request_context.push(3); | |
| 413 | const ticket = acquire("getPendingNotifications") orelse return error.PoolUnexpectedlyFull; | |
| 414 | request_context.pop(); | |
| 415 | ||
| 416 | deliverError(ticket); | |
| 417 | ||
| 418 | slots_mutex.lock(); | |
| 419 | defer slots_mutex.unlock(); | |
| 420 | try testing.expect(slots[ticket.index].failed); | |
| 421 | try testing.expect(slots[ticket.index].json == null); | |
| 422 | ||
| 423 | // Drain by hand: no run loop here to service the dispatch hop. | |
| 424 | slots[ticket.index].failed = false; | |
| 425 | slots[ticket.index].in_use = false; | |
| 426 | slots[ticket.index].generation +%= 1; | |
| 427 | } | |
| @@ -18,6 +18,9 @@ const bridge_mobile_biometric = ; | ||
| 18 | 18 | const bridge_mobile_permissions = ; |
| 19 | 19 | const bridge_mobile_db = ; |
| 20 | 20 | const bridge_mobile_notifcancel = ; |
| 21 | const bridge_mobile_notifications = ; | |
| 22 | const bridge_mobile_bgtasks = ; | |
| 23 | const bridge_mobile_watch = ; | |
| 21 | 24 | |
| 22 | 25 | const objc = objc_runtime.objc; |
| 23 | 26 | |
| @@ -169,8 +172,10 @@ fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, | ||
| 169 | 172 | // another module (or the shim) would replace a specific failure with |
| 170 | 173 | // whatever the next thing happens to say. |
| 171 | 174 | // |
| 172 | // The conformance test guarantees no action appears in two modules' | |
| 173 | // tables, so first-match is deterministic rather than order-dependent. | |
| 175 | // `test/ios_conformance_test.zig` fails the build if one action is | |
| 176 | // declared by two modules, so first-match is deterministic rather than | |
| 177 | // dependent on the order of this tuple. That check was claimed here | |
| 178 | // before it existed; it exists now. | |
| 174 | 179 | inline for (mobile_bridges) |Bridge| { |
| 175 | 180 | var bridge = Bridge.init(allocator); |
| 176 | 181 | defer bridge.deinit(); |
| @@ -217,6 +222,9 @@ const mobile_bridges = .{ | ||
| 217 | 222 | bridge_mobile_permissions.PermissionsBridge, |
| 218 | 223 | bridge_mobile_db.DbBridge, |
| 219 | 224 | bridge_mobile_notifcancel.NotifCancelBridge, |
| 225 | bridge_mobile_notifications.NotificationsBridge, | |
| 226 | bridge_mobile_bgtasks.BgTasksBridge, | |
| 227 | bridge_mobile_watch.WatchBridge, | |
| 220 | 228 | }; |
| 221 | 229 | |
| 222 | 230 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| @@ -38,6 +38,9 @@ const zig_sources = [_][]const u8{ | ||
| 38 | 38 | @embedFile("src/bridge_mobile_permissions.zig"), |
| 39 | 39 | @embedFile("src/bridge_mobile_db.zig"), |
| 40 | 40 | @embedFile("src/bridge_mobile_notifcancel.zig"), |
| 41 | @embedFile("src/bridge_mobile_notifications.zig"), | |
| 42 | @embedFile("src/bridge_mobile_bgtasks.zig"), | |
| 43 | @embedFile("src/bridge_mobile_watch.zig"), | |
| 41 | 44 | }; |
| 42 | 45 | |
| 43 | 46 | /// The action list lives in the `switch action` block, and nowhere else. |
| @@ -65,8 +68,15 @@ const dispatch_end = "func webView("; | ||
| 65 | 68 | /// the secure tier landed flashlight, shortcuts, the Keychain secure store, |
| 66 | 69 | /// biometric persistence, and permissions — including the first async reply; |
| 67 | 70 | /// 70 after the data tier landed SQLite (dbExecute/dbQuery) and the |
| 68 | /// notification cancels. | |
| 69 | const max_not_yet_migrated: usize = 70; | |
| 71 | /// notification cancels; 64 after pending notifications, background-task | |
| 72 | /// scheduling/cancellation, and Watch context/reachability. | |
| 73 | /// | |
| 74 | /// Three actions in that round were deliberately left with the Swift shim | |
| 75 | /// rather than served here: scheduleNotification, registerBackgroundTask and | |
| 76 | /// sendToWatch all hand iOS a callback that fires later, which needs an event | |
| 77 | /// channel iOS does not have yet. Falling through beats `.unavailable`, which | |
| 78 | /// would make Zig dispatch and refuse an action the shim serves correctly. | |
| 79 | const max_not_yet_migrated: usize = 64; | |
| 70 | 80 | |
| 71 | 81 | fn dispatcherRegion() []const u8 { |
| 72 | 82 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |
| @@ -111,6 +121,11 @@ fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) { | ||
| 111 | 121 | var set = std.StringHashMap(void).init(allocator); |
| 112 | 122 | errdefer set.deinit(); |
| 113 | 123 | |
| 124 | // Duplicates are an error, not a merge. `ios_dispatch.route` takes the | |
| 125 | // first module whose handleMessage does not return UnknownAction, so two | |
| 126 | // modules declaring one action makes dispatch order — an implementation | |
| 127 | // detail of a comptime tuple — decide which implementation a page gets. | |
| 128 | // Folding them into one set, as this did, made that invisible. | |
| 114 | 129 | for (zig_sources) |source| { |
| 115 | 130 | var it = std.mem.splitScalar(u8, source, '\n'); |
| 116 | 131 | var in_block = false; |
| @@ -126,7 +141,16 @@ fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) { | ||
| 126 | 141 | // pub const get_device_info = "getDeviceInfo"; |
| 127 | 142 | const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue; |
| 128 | 143 | const close = std.mem.indexOfScalarPos(u8, trimmed, open + 1, '"') orelse continue; |
| 129 | try set.put(trimmed[open + 1 .. close], {}); | |
| 144 | const name = trimmed[open + 1 .. close]; | |
| 145 | if (set.contains(name)) { | |
| 146 | std.debug.print( | |
| 147 | "action '{s}' is declared by more than one mobile module.\n" ++ | |
| 148 | " Dispatch order would silently pick one.\n", | |
| 149 | .{name}, | |
| 150 | ); | |
| 151 | return error.ActionDeclaredTwice; | |
| 152 | } | |
| 153 | try set.put(name, {}); | |
| 130 | 154 | } |
| 131 | 155 | } |
| 132 | 156 | return set; |