ReviewOS

also looking at this

craft-native/craft

iOS Phase 6: notifications, background tasks, Watch — ratchet 70 → 64

#88
Merged glennmichael123 wants to merge feat/ios-phase6-notif into main
10 files +3613 -12
packages/ios/fixtures/zig-slice/build-and-run.shmodified+5-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -90,7 +90,7 @@ LAUNCH_PID=$!
9090# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
9191# fixed amount, so a slow boot does not read as a failure.
9292for _ 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
9494 sleep 1
9595done
9696kill "$LAUNCH_PID" 2>/dev/null || true
@@ -158,4 +158,8 @@ C12="$(count 12)"
158158[ "$C12" -ge 1 ] || { echo "FAIL: the SQLite round trip never returned the bound value"; exit 1; }
159159echo "ok: dbExecute/dbQuery round-tripped through in-process SQLite (i=12 seen ${C12}x)"
160160
161C14="$(count 14)"
162[ "$C14" -ge 1 ] || { echo "FAIL: the async array reply never reached the page"; exit 1; }
163echo "ok: async NSArray completion delivered via ios_async (i=14 seen ${C14}x)"
164
161165echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+10-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -30,7 +30,7 @@ const char craft_slice_page[] =
3030 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
3131 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
3232 "</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"
3434 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
3535 "\n"
3636 "// Read before anything else touches it: the user script is installed at\n"
@@ -85,6 +85,14 @@ const char craft_slice_page[] =
8585 " sentDb = true;\n"
8686 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:12});\n"
8787 " }\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"
8896 " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n"
8997 " sentTierZero = true;\n"
9098 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n"
@@ -108,6 +116,7 @@ const char craft_slice_page[] =
108116 " // not formatted), then read it back through dbQuery.\n"
109117 " 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"
110118 " 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"
111120 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
112121 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
113122 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+9-0
Changes to packages/zig/build.zig
@@ -440,6 +440,15 @@ pub fn build(b: *std.Build) void {
440440 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_notifcancel.zig", .{
441441 .root_source_file = b.path("src/bridge_mobile_notifcancel.zig"),
442442 });
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 });
443452
444453 const menubar_tests = b.addTest(.{
445454 .root_module = b.createModule(.{
packages/zig/src/bridge_mobile_bgtasks.zigadded+1243-0
Changes to packages/zig/src/bridge_mobile_bgtasks.zig
@@ -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
178const std = ;
179const builtin = ;
180const capabilities = ;
181const bridge_error = ;
182const objc_runtime = ;
183
184const objc = objc_runtime.objc;
185const 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)`.
193const 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`.
198const 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.
207pub 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`.
221pub 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.
231const 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*.
236const 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.
241const scheduled_key = "scheduled";
242const 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.
247const 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.
255const Route = enum { schedule, cancel_one, cancel_all };
256
257fn 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
264pub 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.
357const 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.
365const 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.
377fn 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.
384fn 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.
397fn 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.
416fn 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.
441fn 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.
459fn 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.
472fn 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.
486fn 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`.
498fn 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.
522fn 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.
544fn 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.
559fn 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.
575fn 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.
647fn 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.
659fn 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.
673fn 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.
699fn 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.
707fn 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.
718fn 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
735const testing = std.testing;
736
737test "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
750test "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
759test "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
768test "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
794test "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
814test "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
854test "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
868fn parse(json: []const u8) !std.json.Parsed(std.json.Value) {
869 return std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
870}
871
872fn 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
878test "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
894test "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
912test "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
919test "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
926test "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
934test "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
940test "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
957test "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
979test "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
987fn 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
993test "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
1002test "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
1016test "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
1024test "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
1033test "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
1056test "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
1077test "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
1094test "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
1103test "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
1118test "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
1136test "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
1160test "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
1194test "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
1216test "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
Changes to packages/zig/src/bridge_mobile_notifications.zig
@@ -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
114const std = ;
115const builtin = ;
116const capabilities = ;
117const bridge_error = ;
118const objc_runtime = ;
119const ios_async = ;
120const compat_mutex = ;
121
122const objc = objc_runtime.objc;
123const 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)`.
131const 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.
148pub 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`.
159pub 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.
166const Route = enum { get_pending };
167
168fn routeFor(action: []const u8) ?Route {
169 if (std.mem.eql(u8, action, A.get_pending_notifications)) return .get_pending;
170 return null;
171}
172
173pub 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.
236fn 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.
256const 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.
315const ns_utf8_string_encoding: c_ulong = 4;
316
317fn 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.
329const 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.
360fn 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.
373fn 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.
396fn 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.
419fn 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
455const PendingCall = struct {
456 ticket: ios_async.Ticket,
457 sels: Sels,
458};
459
460var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
461var 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.
465fn 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.
473fn 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
481const 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.
488const 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.
499const BLOCK_IS_GLOBAL: c_int = 1 << 28;
500
501const array_block_descriptor = BlockDescriptor{ .size = @sizeOf(ArrayBlock) };
502
503extern var _NSConcreteGlobalBlock: anyopaque;
504
505/// One invoke per slot, comptime-generated so each block knows which slot it is
506/// without capturing anything.
507fn 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
516fn 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
529var array_blocks: [ios_async.max_in_flight]ArrayBlock =
530 if (is_darwin) makeArrayBlocks() else undefined;
531
532fn 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`.
548fn 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
585const testing = std.testing;
586
587test "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
602test "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
609test "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
618test "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
644test "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
683test "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
706test "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
739test "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
749test "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
768test "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
783test "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
808test "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
835test "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
863test "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
873test "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
888test "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
919test "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}
packages/zig/src/bridge_mobile_permissions.zigmodified+25-4
Changes to packages/zig/src/bridge_mobile_permissions.zig
@@ -430,18 +430,39 @@ pub const PermissionsBridge = struct {
430430 func(session, sel, ios_async.boolBlock(ticket));
431431 }
432432
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
433445 /// `-[UNUserNotificationCenter requestAuthorizationWithOptions:completionHandler:]`
434446 /// with a `void(^)(BOOL, NSError *)` from the pool. The error object is
435447 /// discarded by the pool's invoke, exactly as Swift's `{ granted, _ in }`
436448 /// discards it — the granted flag is the whole page contract.
437449 ///
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.
442461 fn requestNotifications() !void {
443462 if (!is_darwin) return error.UnsupportedPlatform;
444463
464 try requireBundleIdentifier();
465
445466 const UNUserNotificationCenter = objc.objc_getClass("UNUserNotificationCenter") orelse
446467 return error.ClassNotFound;
447468 const sel_current = objc.sel_registerName("currentNotificationCenter") orelse
packages/zig/src/bridge_mobile_watch.zigadded+1214-0
Changes to packages/zig/src/bridge_mobile_watch.zig
@@ -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
182const std = ;
183const builtin = ;
184const capabilities = ;
185const bridge_error = ;
186const objc_runtime = ;
187
188const objc = objc_runtime.objc;
189const 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.
198const 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.
208pub 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.
222pub 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
236const reachable_true = "{\"reachable\":true}";
237const reachable_false = "{\"reachable\":false}";
238
239/// `updateWatchContext` has no `false` reply: Swift's failure path *rejects*,
240/// it does not resolve `{"updated":false}`.
241const updated_true = "{\"updated\":true}";
242
243fn 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.
250const Route = enum { update_context, is_reachable };
251
252fn 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
258pub 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.
337fn 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`.
353fn 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.
387fn 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.
459const 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.
463const wc_device_not_paired: c_long = 7005;
464const wc_watch_app_not_installed: c_long = 7006;
465const wc_invalid_parameter: c_long = 7008;
466const wc_payload_too_large: c_long = 7009;
467const 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.
481fn 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`.
518const 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.
523const 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
541const 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.
551fn 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.
578fn 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.
593fn 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.
646fn 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.
672fn 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.
682fn 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
707const testing = std.testing;
708
709test "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
723test "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
731test "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
740test "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
766test "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
789test "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
822test "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
832fn 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
838test "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
861test "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
876test "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
886test "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
902test "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
908test "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
935test "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
943fn 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
950fn 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
959test "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
975test "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
987test "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
1006test "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
1023test "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
1032test "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
1056test "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
1075test "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
1087test "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
1108test "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
1129test "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
1167test "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
1188test "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}
packages/zig/src/ios_async.zigmodified+133-1
Changes to packages/zig/src/ios_async.zig
@@ -59,6 +59,15 @@ const Slot = struct {
5959 /// What the completion resolved. Written on the firing thread, read on
6060 /// the main thread after the dispatch_async_f hop.
6161 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,
6271};
6372
6473var slots: [max_in_flight]Slot = @splat(.{});
@@ -219,6 +228,8 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
219228 var action_len: usize = 0;
220229 var request_id: i64 = -1;
221230 var granted = false;
231 var json: ?[]u8 = null;
232 var failed = false;
222233 {
223234 slots_mutex.lock();
224235 defer slots_mutex.unlock();
@@ -228,9 +239,14 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
228239 @memcpy(action_buf[0..action_len], slot.action_buf[0..action_len]);
229240 request_id = slot.request_id;
230241 granted = slot.granted;
242 json = slot.json;
243 failed = slot.failed;
244 slot.json = null;
245 slot.failed = false;
231246 slot.in_use = false;
232247 slot.generation +%= 1;
233248 }
249 defer if (json) |owned| std.heap.c_allocator.free(owned);
234250
235251 const action = action_buf[0..action_len];
236252
@@ -239,13 +255,72 @@ fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
239255 request_context.push(if (request_id < 0) null else @intCast(request_id));
240256 defer request_context.pop();
241257
258 if (failed) {
259 bridge_error.sendErrorToJS(
260 std.heap.c_allocator,
261 action,
262 bridge_error.BridgeError.NativeCallFailed,
263 );
264 return;
265 }
266
242267 bridge_error.sendResultToJS(
243268 std.heap.c_allocator,
244269 action,
245 if (granted) "\"granted\"" else "\"denied\"",
270 json orelse if (granted) "\"granted\"" else "\"denied\"",
246271 );
247272}
248273
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.
288pub 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.
314pub 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
249324const testing = std.testing;
250325
251326test "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" {
293368 const long: [65]u8 = @splat('a');
294369 try testing.expect(acquire(&long) == null);
295370}
371
372test "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
394test "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
407test "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}
packages/zig/src/ios_dispatch.zigmodified+10-2
Changes to packages/zig/src/ios_dispatch.zig
@@ -18,6 +18,9 @@ const bridge_mobile_biometric = ;
1818const bridge_mobile_permissions = ;
1919const bridge_mobile_db = ;
2020const bridge_mobile_notifcancel = ;
21const bridge_mobile_notifications = ;
22const bridge_mobile_bgtasks = ;
23const bridge_mobile_watch = ;
2124
2225const objc = objc_runtime.objc;
2326
@@ -169,8 +172,10 @@ fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8,
169172 // another module (or the shim) would replace a specific failure with
170173 // whatever the next thing happens to say.
171174 //
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.
174179 inline for (mobile_bridges) |Bridge| {
175180 var bridge = Bridge.init(allocator);
176181 defer bridge.deinit();
@@ -217,6 +222,9 @@ const mobile_bridges = .{
217222 bridge_mobile_permissions.PermissionsBridge,
218223 bridge_mobile_db.DbBridge,
219224 bridge_mobile_notifcancel.NotifCancelBridge,
225 bridge_mobile_notifications.NotificationsBridge,
226 bridge_mobile_bgtasks.BgTasksBridge,
227 bridge_mobile_watch.WatchBridge,
220228};
221229
222230/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+27-3
Changes to packages/zig/test/ios_conformance_test.zig
@@ -38,6 +38,9 @@ const zig_sources = [_][]const u8{
3838 @embedFile("src/bridge_mobile_permissions.zig"),
3939 @embedFile("src/bridge_mobile_db.zig"),
4040 @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"),
4144};
4245
4346/// The action list lives in the `switch action` block, and nowhere else.
@@ -65,8 +68,15 @@ const dispatch_end = "func webView(";
6568/// the secure tier landed flashlight, shortcuts, the Keychain secure store,
6669/// biometric persistence, and permissions — including the first async reply;
6770/// 70 after the data tier landed SQLite (dbExecute/dbQuery) and the
68/// notification cancels.
69const 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.
79const max_not_yet_migrated: usize = 64;
7080
7181fn dispatcherRegion() []const u8 {
7282 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) {
111121 var set = std.StringHashMap(void).init(allocator);
112122 errdefer set.deinit();
113123
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.
114129 for (zig_sources) |source| {
115130 var it = std.mem.splitScalar(u8, source, '\n');
116131 var in_block = false;
@@ -126,7 +141,16 @@ fn collectZigActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
126141 // pub const get_device_info = "getDeviceInfo";
127142 const open = std.mem.indexOfScalar(u8, trimmed, '"') orelse continue;
128143 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, {});
130154 }
131155 }
132156 return set;