ReviewOS

also looking at this

craft-native/craft

iOS Phase 7: location and motion streams — ratchet 64 → 58

#89
Merged glennmichael123 wants to merge feat/ios-phase7-events into main
9 files +5347 -2
packages/ios/fixtures/zig-slice/build-and-run.shmodified+10-0
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -162,4 +162,14 @@ C14="$(count 14)"
162162[ "$C14" -ge 1 ] || { echo "FAIL: the async array reply never reached the page"; exit 1; }
163163echo "ok: async NSArray completion delivered via ios_async (i=14 seen ${C14}x)"
164164
165C16="$(count 16)"
166# The simulator has no device motion. A module that fabricated a stream would
167# fire craftMotionUpdate and trip i=16; an honest one refuses and never does.
168[ "$C16" -eq 0 ] || { echo "FAIL: motion emitted on a simulator that has no motion sensor"; exit 1; }
169echo "ok: unavailable sensor refused rather than faking a stream (i=16 absent)"
170
171C60="$(count 60)"
172[ "$C60" -ge 1 ] || { echo "FAIL: startMotionUpdates never reached the dispatcher"; exit 1; }
173echo "ok: event-driven action dispatched to Zig (i=60 seen ${C60}x)"
174
165175echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+8-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -30,6 +30,10 @@ 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 "window.addEventListener('craftMotionUpdate', function () {\n"
34 " if (!sentEvent) { sentEvent = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:16}); }\n"
35 "});\n"
36 "var sentEvent = false;\n"
3337 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n"
3438 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
3539 "\n"
@@ -116,7 +120,10 @@ const char craft_slice_page[] =
116120 " // not formatted), then read it back through dbQuery.\n"
117121 " 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"
118122 " 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"
123 " bridge.postMessage({t:'mobile', a:'getPendingNotifications', i:50});\n" " // Motion is unavailable on a simulator, so this must REJECT — an\n"
124 " // honest refusal, not a fabricated stream of zeros. i=16 stays unsent,\n"
125 " // which is the assertion.\n"
126 " bridge.postMessage({t:'mobile', a:'startMotionUpdates', i:60});\n"
120127 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
121128 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
122129 " // 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
@@ -449,6 +449,15 @@ pub fn build(b: *std.Build) void {
449449 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_watch.zig", .{
450450 .root_source_file = b.path("src/bridge_mobile_watch.zig"),
451451 });
452 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_location.zig", .{
453 .root_source_file = b.path("src/bridge_mobile_location.zig"),
454 });
455 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_locrecording.zig", .{
456 .root_source_file = b.path("src/bridge_mobile_locrecording.zig"),
457 });
458 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_motion.zig", .{
459 .root_source_file = b.path("src/bridge_mobile_motion.zig"),
460 });
452461
453462 const menubar_tests = b.addTest(.{
454463 .root_module = b.createModule(.{
packages/zig/src/bridge_mobile_location.zigadded+2268-0
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -0,0 +1,2268 @@
1//! The geolocation actions of the `mobile` namespace: `getCurrentPosition`,
2//! `watchPosition` and `clearWatch`.
3//!
4//! The last two were absent from the `A` block until now, for one recorded
5//! reason: `ios_events.emit` took a `capabilities.Channel`, whose location
6//! names are `craft:location:update` and `craft:location:error`, and no iOS
7//! page listens for either — every subscriber in the injected JS listens for
8//! `craftLocationUpdate`. A subscribe that resolves `true` and then delivers
9//! nothing is fabricated success wearing a subscription, so the shim kept both.
10//!
11//! `ios_events` now carries its own `Event` enum taken from Swift's
12//! `sendToWeb` call sites — `.location_update` *is* `"craftLocationUpdate"`,
13//! `.location_error` *is* `"craftLocationError"`. The name can be spelled, so
14//! the stream can be served, so both actions land here. Nothing else about the
15//! argument changed: `clearWatch` still only makes sense in the same module as
16//! `watchPosition`, and they migrate together.
17//!
18//! Two JS surfaces reach `getCurrentPosition`, and both end up in the same
19//! place. The legacy `craft.geolocation.getCurrentPosition()` posts
20//! `{action:'getCurrentPosition', callbackId: id}` and builds its `Promise`
21//! by hand rather than through `_createCallback` — so it has **no timeout**;
22//! an unanswered call parks the page forever. The v1 wrapper
23//! `craft.location.getCurrentPosition(options)` forwards straight to it. That
24//! is why every path below ends in a reply or an error, and why
25//! `didFailWithError` must answer rather than log.
26//!
27//! ## What is carried across exactly, because it is the observable contract
28//!
29//! - **No payload crosses the bridge.** `legacyGeolocation.getCurrentPosition`
30//! is declared `function()` — zero parameters — so the `options` object the
31//! v1 wrapper accepts (`enableHighAccuracy`, `timeout`, `maximumAge`, typed
32//! in `packages/typescript/src/api/mobile.ts`) is dropped by the *page*,
33//! before native sees anything. This is not a field this module drops: the
34//! field never arrives. `d` is therefore ignored rather than parsed, exactly
35//! as the Swift dispatcher reads nothing out of `body` for this action —
36//! parsing it would invent a way for the call to fail that the shim does not
37//! have.
38//! - **The reply is an object with eight keys**, from `didUpdateLocations`:
39//! `latitude`, `longitude`, `altitude`, `accuracy`, `altitudeAccuracy`,
40//! `heading`, `speed`, `timestamp`. Swift resolves a `[String: Any]`
41//! dictionary, and `.fragmentsAllowed` permits a bare value but never
42//! unwraps a dictionary into one. Swift's key order is a `Dictionary` and
43//! therefore arbitrary; one order is fixed here so the bytes are testable,
44//! the same call `bridge_mobile_notifications.zig` makes for `PendingList`.
45//! - **Three key names do not match their sources, and all three are easy to
46//! get wrong.** `accuracy` is `horizontalAccuracy`; `altitudeAccuracy` is
47//! `verticalAccuracy`; `heading` is **`course`** — the direction of travel,
48//! not a `CLHeading` compass reading, which nothing in this path ever asks
49//! for. The desktop's `bridge_location.zig` emits `horizontalAccuracy` and
50//! `verticalAccuracy` verbatim and has no `heading` at all; those are a
51//! different contract and copying them here would rename three fields out
52//! from under the page.
53//! - **CoreLocation's `-1` sentinels are passed through raw, not nulled.**
54//! The Swift dictionary has no `nil` checks, no `>= 0` guards and no
55//! coalescing, so an invalid `horizontalAccuracy`, `verticalAccuracy`,
56//! `speed` or `course` lands on the wire as `-1`. `Location.accuracy` is
57//! typed `number`, not `number | null`; nulling would be a divergence the
58//! page is not written for.
59//! - **`timestamp` is fractional milliseconds** — `timeIntervalSince1970 *
60//! 1000`, an `f64`, not an integer.
61//!
62//! ## What is deliberately not carried across
63//!
64//! **The rejection text.** Swift rejects with `error.localizedDescription` and
65//! the default code `CRAFT_ERROR`; `ios_async.deliverError` yields
66//! `NativeCallFailed` → `NATIVE_CALL_FAILED`, because `sendErrorToJS` carries a
67//! `BridgeError` enum and there is no free-text channel. Precedented loss —
68//! `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document the same
69//! trade — and the `NSError` domain, code and `localizedDescription` are all
70//! logged before the enum is chosen.
71//!
72//! **`didUpdateLocations` with an empty array.** Swift's `guard let location =
73//! locations.last else { return }` replies nothing at all, which on an untimed
74//! promise is a hang forever. Here it is an error. Same divergence, same
75//! argument, as `bridge_mobile_watch.zig`'s missing-`context` note.
76//!
77//! **Swift's orphaned first caller.** `getCurrentPosition` assigns
78//! `singleLocationCallbackId = callbackId` unconditionally, so a second call
79//! while one is in flight overwrites the first id and the first promise never
80//! settles. Here the displaced call is rejected instead — see "One native slot
81//! for the one-shot, one flag for the watch, one mutex for both" below for the
82//! cost that buys.
83//!
84//! **Nothing about `craftLocationError` any more.** A paragraph here used to
85//! record that Swift's `didFailWithError` both rejects the caller *and* fires
86//! `craftLocationError` while this module could only reject. It now does both,
87//! in Swift's order, because `ios_events.Event.location_error` spells exactly
88//! that name. Two things about it are worth keeping straight: nothing in the
89//! injected JS or in `test-bridges.html` subscribes to `craftLocationError`
90//! today — the only listeners anywhere are for `craftLocationUpdate` — so it
91//! is emitted because the Swift app a page may have been written against emits
92//! it, not because a subscriber is known to exist; and it is fired
93//! *unconditionally*, exactly as Swift's is, rather than only while a watch is
94//! running. A watch that hits an error has no reply left to reject — its
95//! `true` went out when it started — so the event is the only channel it has.
96//!
97//! **The recording side effect.** Swift's `didUpdateLocations` calls
98//! `appendRecordedLocation(data)` for *every* fix, including a one-shot
99//! `requestLocation`, so calling `getCurrentPosition` during an active
100//! recording appends one extra sample to the track that `readLocationRecording`
101//! later returns. This module has its own `CLLocationManager`, so that sample
102//! is no longer added. Arguably the more correct behaviour — a one-shot query
103//! is not part of a route — but it *is* a behaviour change, and a page counting
104//! samples will see one fewer.
105//!
106//! **One `craftLocationUpdate` per manager while a recording is running.** The
107//! six `*LocationRecording` actions stay with the shim
108//! (`bridge_mobile_locrecording.zig` serves only `readLocationRecording`), so a
109//! recording runs Swift's manager and Swift's `didUpdateLocations`, which fires
110//! `craftLocationUpdate` for every fix whenever `isRecordingLocation` is true —
111//! watch or no watch. A page that records *and* watches therefore has two
112//! managers feeding one event name, and its watch callback runs roughly twice
113//! as often as it did under a single shared manager. Every one of those events
114//! carries a real fix; none is fabricated and none is dropped. It is a
115//! duplication, it is visible to a page that counts samples, and it disappears
116//! when the recording actions migrate.
117//!
118//! The converse loss is gone rather than added to: a `getCurrentPosition`
119//! issued while *this* module's watch is running is broadcast to the watch
120//! subscribers again, because one `didUpdateLocations` settles the one-shot and
121//! emits the same fix, exactly as Swift's does.
122//!
123//! ## The cross-action cost, stated rather than argued away
124//!
125//! `locationManager.delegate = self` is set in exactly four Swift places:
126//! `getCurrentPosition`, `watchPosition`, `startLocationRecording` and
127//! `restoreLocationRecordingState`. The `requestPermission` arm for
128//! `location`/`locationAlways` does **not** set it — it only assigns
129//! `locationPermissionCallbackId` and calls `requestWhenInUseAuthorization` —
130//! and that request is settled *only* by
131//! `locationManagerDidChangeAuthorization` firing on Swift's manager.
132//! `bridge_mobile_permissions.zig` returns `UnknownAction` for those two
133//! permission values, so `requestPermission('location')` is still shim-served.
134//!
135//! Taking `getCurrentPosition` into Zig — with Zig's own manager and its own
136//! delegate — removes one of the two commonly-hit paths that gave Swift's
137//! manager a delegate. `craft.permissions.request('location')` therefore
138//! resolves in fewer orderings than before: it already fails today when it is
139//! the first location call a page makes, and after this it also fails when
140//! `getCurrentPosition` was the only call before it. Taking `watchPosition`
141//! removes the *second* of those two paths: after this change the only things
142//! left that give Swift's manager a delegate are `startLocationRecording` and
143//! `restoreLocationRecordingState`. So `craft.permissions.request('location')`
144//! now settles only in an app that has started or restored a recording. That is
145//! a narrowing of an already-broken path rather than a new break — it was
146//! already broken for the first location call any page makes — but it is
147//! narrower than it was before this change, and the earlier wording ("watchPosition
148//! and the three recording actions still install Swift's delegate") is no
149//! longer true. The fix is to migrate `requestPermission` for location in
150//! `bridge_mobile_permissions.zig`, which is a second file this pass is not
151//! writing.
152//!
153//! ## What `watchPosition` and `clearWatch` owe the page, exactly
154//!
155//! **Neither carries a payload, and `clearWatch` carries no watch id.**
156//! `nextLocationWatchId` is a pure JS-side map key: `craft.location.watchPosition`
157//! hands the page an incrementing integer, keeps its callbacks in a `Map`, and
158//! only posts to native when the map goes from empty to one and back. The
159//! native side has a single on/off, and both JS surfaces post
160//! `{action:'clearWatch'}` with no `id`. So the `id` is not a field this module
161//! drops — it never crosses the bridge, exactly as `options` does not for
162//! `getCurrentPosition`.
163//!
164//! **Both replies are the bare fragment `true`.** Swift is
165//! `resolveCallback(callbackId, result: true)` for each, and `resolveCallback`
166//! serialises with `.fragmentsAllowed`, which permits a bare value. Not
167//! `{"ok":true}`, not `{"watchId":1}` — the opposite of
168//! `bridge_mobile_watch.zig`, whose replies are objects because Swift resolved
169//! dictionaries there. `craft-bridge.js` resolves with `payload || {}`, so a
170//! falsy reply would arrive as `{}`; `true` stays truthy on purpose.
171//!
172//! **`watchPosition` replies synchronously.** Its `resolveCallback` is the last
173//! line of the Swift method, straight after `startUpdatingLocation()` — it is
174//! not a delegate callback and does not wait for a fix. So this module answers
175//! through `bridge_error.sendResultToJS` inside the dispatch, and never leases
176//! an `ios_async` slot for it. A slot would be held for the life of the
177//! subscription and would narrow the pool for every other call.
178//!
179//! **The stream is the eight-key fix object, on `craftLocationUpdate`.**
180//! `didUpdateLocations` builds one dictionary and both resolves it to the
181//! one-shot and hands it to `sendToWeb`, so the event `detail` and the
182//! `getCurrentPosition` reply are the *same shape*. That is why `shapeFix` runs
183//! once below and its bytes are used twice.
184//!
185//! **`clearWatch` is not gated on `enableGeolocation`.** `case "clearWatch":`
186//! in the Swift dispatcher has no capability check at all — unlike
187//! `getCurrentPosition`, `watchPosition` and `startLocationRecording`, which
188//! are each wrapped in one. So `clearWatch` here does not consult the
189//! Info.plist gate either: a page can always stop a watch, including in an app
190//! that could never have started one, where it is a no-op that still replies
191//! `true`.
192//!
193//! ## One delegate, two consumers — the whole risk in this change
194//!
195//! The delegate now serves a one-shot `requestLocation` *and* a running
196//! `startUpdatingLocation` at the same time, and they interact three ways.
197//!
198//! **A fix while both are live must settle the one-shot and emit, in that
199//! order.** Swift's `didUpdateLocations` resolves `singleLocationCallbackId`
200//! first, clears it, and only then calls `sendToWeb`. `takeDelegateWork` reads
201//! both under one lock — taking the pending fix, leaving the watch — so the two
202//! are one decision that cannot interleave, and `ios_async.deliverJson` is
203//! enqueued before `ios_events.emit`. Both hops target `_dispatch_main_q`,
204//! which is serial, so enqueue order *is* delivery order.
205//!
206//! It is worth being explicit about where that fix comes from, because
207//! `requestLocation()` and `startUpdatingLocation()` are not designed to be
208//! combined: with a watch running, a `getCurrentPosition` is answered by the
209//! *stream's* next callback rather than by its own request, since one
210//! `didUpdateLocations` serves whatever is waiting. Swift's shim has exactly
211//! this arrangement on exactly one manager, so the behaviour is the same one
212//! the page sees today — and it is why the one-shot is preferred as the source
213//! of the shaping selectors: the reply is built from the selectors captured for
214//! the call being answered.
215//!
216//! **A fix consumes the one-shot and never the watch.** A one-shot is answered
217//! once; a subscription is answered until it is cleared. Clearing the watch the
218//! way the pending fix is cleared would deliver exactly one event and then go
219//! silent with the page's callback still registered — the failure mode that is
220//! invisible from the page, because a stream has no way to say "that was the
221//! last one".
222//!
223//! **`clearWatch` must not strand a one-shot.** CoreLocation documents
224//! `stopUpdatingLocation()` as cancelling a pending `requestLocation()`, so the
225//! fix that would have answered an in-flight `getCurrentPosition` is not
226//! coming, and its promise is the untimed hand-built kind: a page parked
227//! forever. So the stop takes the pending fix out with it, under the same lock,
228//! and rejects it. Swift has the same cancellation and no such handling — it
229//! relies on CoreLocation reporting the cancellation through `didFailWithError`,
230//! which is not guaranteed. Rejecting is the same outcome, made certain.
231//!
232//! The point above closes the only door that might have been left open: with a
233//! watch running, a pending one-shot would otherwise be answered by the
234//! stream's next fix — but the stop ends the stream too, so after a
235//! `clearWatch` there is no callback left from which any answer could come.
236//!
237//! The corollary is deliberate: a `clearWatch` that stops **nothing** takes
238//! nothing. Swift's `stopWatchingPosition()` never consults
239//! `isWatchingLocation` before calling `stopUpdatingLocation()` — its only
240//! guard is `if !isRecordingLocation` — so in Swift a `clearWatch` with no
241//! watch running still cancels an in-flight `getCurrentPosition`, provided no
242//! recording is up. This module only stops what it started, so that one
243//! survives. That is a divergence, it is in the direction of answering a caller
244//! that Swift may leave hanging, and it is stated rather than smoothed over.
245//!
246//! One thing this module does *not* need and Swift does: the
247//! `if !isRecordingLocation` guard in `stopWatchingPosition()`. That guard
248//! exists because Swift's single manager serves the watch and the recorder at
249//! once. The recording actions are shim-served against Swift's own manager, so
250//! stopping *this* module's manager cannot interrupt a recording; there is no
251//! Coordinator state to read, and no guard is claimed for one.
252//!
253//! ## `allowsBackgroundLocationUpdates`, and the exception it can raise
254//!
255//! CoreLocation raises an Objective-C exception when it is set to `YES` in a
256//! process whose `UIBackgroundModes` lacks `location`, and an uncaught ObjC
257//! exception is an uncatchable SIGABRT rather than an error to map — the same
258//! class of hazard `bridge_mobile_notifcancel.zig`'s bundle guard exists for.
259//!
260//! `getCurrentPosition` still never touches it, because Swift's does not:
261//! only `watchPosition` and the recording actions call
262//! `configureBackgroundLocationIfNeeded()`. `watchPosition` does, gated on the
263//! condition that actually throws — `UIBackgroundModes` containing `location`,
264//! read from the running process — rather than on the usage-description key.
265//! `packages/ios/src/index.ts` writes the mode (`:232`) and
266//! `NSLocationAlwaysAndWhenInUseUsageDescription` (`:188`) from the same
267//! `enableBackgroundLocation` flag, so in a generated app the two always agree
268//! and this is exactly `config.enableBackgroundLocation`; in a hand-edited
269//! plist carrying the usage string without the mode, inferring from the key
270//! would be a crash rather than a divergence.
271//!
272//! It is set rather than left alone because leaving it alone is not free:
273//! without it a watch stops delivering the moment the app is backgrounded, and
274//! for an app that set `enableBackgroundLocation` that is the entire feature.
275//! The page sees no error when that happens — the stream simply goes quiet — so
276//! the one case where it genuinely cannot be set (a `CLLocationManager` that
277//! does not respond to the selector at all) is logged rather than passed over.
278//! `clearWatch` sets it back to NO, mirroring `stopWatchingPosition()`; setting
279//! it to NO is safe in any process, because only YES can raise.
280
281const std = ;
282const builtin = ;
283const capabilities = ;
284const bridge_error = ;
285const objc_runtime = ;
286const ios_async = ;
287const ios_events = ;
288const compat_mutex = ;
289
290const objc = objc_runtime.objc;
291const is_darwin = builtin.target.os.tag.isDarwin();
292
293/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
294/// notifications/watch precedent: `objc_runtime.objc` is an empty struct off
295/// Darwin and a function *signature* is analysed even when a comptime platform
296/// guard prunes the body, so naming `objc.id` in the `callconv(.c)` types below
297/// would break the host build. A single optional pointer, never `?objc.id` — a
298/// double optional is illegal in `callconv(.c)`.
299const Id = ?*anyopaque;
300
301/// The action names, spelled exactly as the Swift `case` labels spell them.
302/// `test/ios_conformance_test.zig` matches this block against those labels in
303/// both directions, and fails the build if two modules declare one name.
304///
305/// An action listed here is an action taken away from the Swift shim, and that
306/// trade is only worth making when Zig's answer is at least as good. For
307/// `watchPosition` that turned on one fact — whether `ios_events` could spell
308/// `craftLocationUpdate` — and it now can.
309pub const A = struct {
310 pub const get_current_position = "getCurrentPosition";
311 pub const watch_position = "watchPosition";
312 pub const clear_watch = "clearWatch";
313};
314
315/// `.result`: the Swift path terminates in exactly one `resolveCallback` (from
316/// `didUpdateLocations`) or one `rejectCallback` (from `didFailWithError`), and
317/// the JS promise is the untimed hand-built kind — `.none` here would strand a
318/// caller forever rather than for thirty seconds.
319///
320/// `.live`: it dispatches and does the thing. `.unavailable` is for an action
321/// that dispatches and refuses, which this is not — a refusal here is a
322/// specific failure (geolocation not configured, pool full, CoreLocation
323/// error), never the action's normal answer.
324///
325/// All three are `.result`, and for `watchPosition` and `clearWatch` that is
326/// the *synchronous* kind: Swift resolves each with a bare `true` from the
327/// dispatcher itself. `.none` would be wrong for a different reason there —
328/// `craft._invoke` awaits both, and an unanswered await is a thirty-second
329/// stall followed by a timeout rejection for a subscription that is actually
330/// running.
331pub const capability_actions = [_]capabilities.ActionDecl{
332 .{ .name = A.get_current_position, .reply = .result },
333 .{ .name = A.watch_position, .reply = .result },
334 .{ .name = A.clear_watch, .reply = .result },
335};
336
337/// The reply `watchPosition` and `clearWatch` send.
338///
339/// Swift's `resolveCallback(callbackId, result: true)` through
340/// `.fragmentsAllowed` is the bare fragment `true` — not `{"ok":true}`, and not
341/// a watch id, which is a JS-side map key that never crosses the bridge. Static,
342/// so replying allocates nothing.
343const true_fragment = "true";
344
345/// Which handler an action selects, split out from `handleMessage` so the
346/// table-versus-dispatch agreement is assertable on a host without touching
347/// CoreLocation.
348const Route = enum { current_position, watch_position, clear_watch };
349
350fn routeFor(action: []const u8) ?Route {
351 if (std.mem.eql(u8, action, A.get_current_position)) return .current_position;
352 if (std.mem.eql(u8, action, A.watch_position)) return .watch_position;
353 if (std.mem.eql(u8, action, A.clear_watch)) return .clear_watch;
354 return null;
355}
356
357pub const LocationBridge = struct {
358 allocator: std.mem.Allocator,
359
360 const Self = @This();
361
362 pub fn init(allocator: std.mem.Allocator) Self {
363 return .{ .allocator = allocator };
364 }
365
366 pub fn deinit(_: *Self) void {}
367
368 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
369 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
370 // Exhaustive, so a `Route` without a handler is a compile error.
371 return switch (route) {
372 .current_position => self.getCurrentPosition(data),
373 .watch_position => self.watchPosition(data),
374 .clear_watch => self.clearWatch(data),
375 };
376 }
377
378 /// Ask CoreLocation for one fix and answer when it arrives.
379 ///
380 /// `data` is accepted and ignored: the page drops `options` before posting
381 /// (`legacyGeolocation.getCurrentPosition` takes no parameters) and the
382 /// Swift dispatcher reads nothing out of `body`. Parsing it would invent a
383 /// failure mode the shim does not have.
384 ///
385 /// Every fallible step — the Info.plist gate, all nine reply selectors, the
386 /// delegate class, the manager, the two request selectors — runs *before*
387 /// `ios_async.acquire`, so there is no error path between leasing a slot
388 /// and handing the request to the framework. A failure after the lease
389 /// would have to release the slot by hand, and a missed release is a
390 /// permanently narrower pool.
391 fn getCurrentPosition(self: *Self, data: []const u8) !void {
392 _ = self;
393 _ = data;
394 if (!is_darwin) return error.UnsupportedPlatform;
395
396 const authorization = try resolveAuthorization();
397 const sels = try Sels.resolve();
398 const mgr = try ensureManager();
399
400 const auth_selector_name: [*:0]const u8 = switch (authorization) {
401 .always => "requestAlwaysAuthorization",
402 .when_in_use => "requestWhenInUseAuthorization",
403 };
404 const sel_authorize = try selector(auth_selector_name);
405 const sel_request = try selector("requestLocation");
406
407 const ticket = ios_async.acquire(A.get_current_position) orelse return poolFull();
408
409 // Published before the framework call, never after: `requestLocation`
410 // is asynchronous but the delegate can in principle be reached before
411 // `msgSend` returns, and a callback that arrived at an empty slot would
412 // have no ticket to reply with.
413 if (publishPendingFix(ticket, sels)) |displaced| {
414 // Swift overwrites `singleLocationCallbackId` here and the first
415 // caller's untimed promise never settles. Rejecting it is strictly
416 // better: every caller ends up settled. See "One native slot for
417 // the one-shot, ..." below for what this costs.
418 std.log.warn(
419 "getCurrentPosition: a second call displaced one still in flight; " ++
420 "rejecting the first rather than orphaning it",
421 .{},
422 );
423 ios_async.deliverError(displaced.ticket);
424 }
425
426 objc.msgSend(mgr, sel_authorize);
427 objc.msgSend(mgr, sel_request);
428 }
429
430 /// Start the continuous stream, and answer `true` for having started it.
431 ///
432 /// Swift's order is delegate, `isWatchingLocation = true`,
433 /// `requestLocationAuthorization()`, `configureBackgroundLocationIfNeeded()`,
434 /// `startUpdatingLocation()`, `resolveCallback(… true)`, and that order is
435 /// kept: the watch is published *before* the framework call, because a fix
436 /// that reached the delegate first would find no watch recorded and be
437 /// dropped as a stray.
438 ///
439 /// `data` is accepted and ignored, as it is for `getCurrentPosition`. Both
440 /// JS surfaces post the action alone — the legacy
441 /// `geolocation.watchPosition(callback)` keeps its callback in the page and
442 /// the v1 `craft.location.watchPosition(callback)` keeps its callback in a
443 /// `Map` — so there is no field here to drop.
444 ///
445 /// Calling it twice is what the page does when a second subscriber appears
446 /// after a `clearWatch` raced it, and it is idempotent in Swift: the flag is
447 /// re-set, authorization re-requested, `startUpdatingLocation` re-issued
448 /// (a documented no-op on a manager already updating), `true` re-resolved.
449 /// It is idempotent here for the same reasons.
450 ///
451 /// Every fallible step runs before the watch is published, so there is no
452 /// error path between recording a subscription and starting the stream that
453 /// would leave the module believing it is watching when nothing is running.
454 fn watchPosition(self: *Self, data: []const u8) !void {
455 _ = data;
456 if (!is_darwin) return error.UnsupportedPlatform;
457
458 const authorization = try resolveAuthorization();
459 const sels = try Sels.resolve();
460 const background = try backgroundLocationDeclared();
461 const mgr = try ensureManager();
462
463 const auth_selector_name: [*:0]const u8 = switch (authorization) {
464 .always => "requestAlwaysAuthorization",
465 .when_in_use => "requestWhenInUseAuthorization",
466 };
467 const sel_authorize = try selector(auth_selector_name);
468 const sel_start = try selector("startUpdatingLocation");
469 const sel_responds = try selector("respondsToSelector:");
470 const sel_allows_background = try selector("setAllowsBackgroundLocationUpdates:");
471 const sel_shows_indicator = try selector("setShowsBackgroundLocationIndicator:");
472
473 publishWatch(sels);
474
475 objc.msgSend(mgr, sel_authorize);
476
477 if (background) {
478 // `configureBackgroundLocationIfNeeded()`, under the guard that
479 // makes it safe. Both properties take the same value in Swift, and
480 // both are set to it here.
481 if (!setBoolIfSupported(mgr, sel_responds, sel_allows_background, true)) {
482 std.log.warn(
483 "watchPosition: this CLLocationManager has no " ++
484 "allowsBackgroundLocationUpdates; the watch will stop when the app " ++
485 "is backgrounded even though UIBackgroundModes declares location",
486 .{},
487 );
488 }
489 // Purely the status-bar indicator. A manager without it is an OS too
490 // old for the property, not a broken subscription, so it is not
491 // worth a warning of its own.
492 _ = setBoolIfSupported(mgr, sel_responds, sel_shows_indicator, true);
493 }
494
495 objc.msgSend(mgr, sel_start);
496
497 bridge_error.sendResultToJS(self.allocator, A.watch_position, true_fragment);
498 }
499
500 /// Stop the stream this module started, and answer `true`.
501 ///
502 /// No Info.plist gate: `case "clearWatch":` in the Swift dispatcher carries
503 /// no `config.enableGeolocation` check, so borrowing `getCurrentPosition`'s
504 /// gate would refuse a stop the shim always performs.
505 ///
506 /// `data` is ignored, and there is no watch id in it to ignore — see the
507 /// module comment. Stopping is unconditional in the sense that matters: the
508 /// page gets `true` whether or not anything was running, exactly as Swift
509 /// does, because "there is no watch" and "the watch is now stopped" are the
510 /// same state from a caller's point of view.
511 fn clearWatch(self: *Self, data: []const u8) !void {
512 _ = data;
513 if (!is_darwin) return error.UnsupportedPlatform;
514
515 // All three selectors resolved while an error is still deliverable, and
516 // before any state changes: a failure after `takeWatchAndPending` would
517 // leave the module reporting no watch while the manager kept updating.
518 const sel_stop = try selector("stopUpdatingLocation");
519 const sel_responds = try selector("respondsToSelector:");
520 const sel_allows_background = try selector("setAllowsBackgroundLocationUpdates:");
521
522 const stop = takeWatchAndPending();
523
524 // `stopUpdatingLocation` cancels a pending `requestLocation`, so a
525 // one-shot in flight is not going to be answered by CoreLocation. Its
526 // promise has no timeout, so leaving it is a page parked forever.
527 //
528 // Settled *here*, above every remaining exit from this function, rather
529 // than after the stop: `takeWatchAndPending` has already removed it from
530 // `pending_fix` under the lock, so no delegate callback can find it any
531 // more. From that line on, the only question is whether anybody tells
532 // the caller — and any path that returns without answering strands it.
533 // The manager lookup below is such a path.
534 //
535 // Null whenever `stop.watching` is false: nothing was stopped, so
536 // nothing was cancelled, so there is nothing to reject.
537 if (stop.pending) |call| {
538 std.log.warn(
539 "clearWatch: stopUpdatingLocation cancels the in-flight getCurrentPosition; " ++
540 "rejecting it rather than leaving its promise unsettled",
541 .{},
542 );
543 ios_async.deliverError(call.ticket);
544 }
545
546 if (!stop.watching) {
547 // Nothing of ours is running, so nothing is stopped and — unlike
548 // Swift — no in-flight `getCurrentPosition` is cancelled.
549 bridge_error.sendResultToJS(self.allocator, A.clear_watch, true_fragment);
550 return;
551 }
552
553 const mgr = manager orelse {
554 // Not reachable: a watch is only ever published after
555 // `ensureManager` returned, and `manager` is never cleared. Saying
556 // so beats replying `true` for a stop with nothing behind it.
557 std.log.err("clearWatch: a watch was recorded with no CLLocationManager behind it", .{});
558 return bridge_error.BridgeError.NativeCallFailed;
559 };
560
561 objc.msgSend(mgr, sel_stop);
562 // Mirrors `stopWatchingPosition()`'s `allowsBackgroundLocationUpdates =
563 // false`. Setting it to NO is safe in any process — only YES can raise.
564 _ = setBoolIfSupported(mgr, sel_responds, sel_allows_background, false);
565
566 bridge_error.sendResultToJS(self.allocator, A.clear_watch, true_fragment);
567 }
568};
569
570/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
571/// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes'
572/// designated stand-in, and the point is that the seventeenth concurrent call
573/// gets an explicit rejection instead of a promise that never settles.
574fn poolFull() bridge_error.BridgeError {
575 std.log.warn(
576 "getCurrentPosition refused: all {d} async slots in flight",
577 .{ios_async.max_in_flight},
578 );
579 return bridge_error.BridgeError.InvalidParameter;
580}
581
582// =============================================================================
583// Reply shaping. Pure — no Objective-C — so the exact bytes the page receives
584// are pinned by host tests on every platform.
585// =============================================================================
586
587/// One CLLocation, read into plain doubles.
588///
589/// The fields are named for their **sources**, not for the wire keys they fill,
590/// so `shapeFix` has to perform the three renames visibly and a test can pin
591/// them. Getting `heading` <- `course` or `accuracy` <- `horizontalAccuracy`
592/// backwards is silent: every value is a plausible-looking double.
593const Fix = struct {
594 latitude: f64,
595 longitude: f64,
596 altitude: f64,
597 horizontal_accuracy: f64,
598 vertical_accuracy: f64,
599 course: f64,
600 speed: f64,
601 /// `timeIntervalSince1970 * 1000` — fractional milliseconds, not an integer.
602 timestamp_ms: f64,
603};
604
605/// The eight-key object Swift's `didUpdateLocations` resolves.
606///
607/// Key order is fixed at latitude, longitude, altitude, accuracy,
608/// altitudeAccuracy, heading, speed, timestamp. Swift's is a `Dictionary` and
609/// therefore arbitrary; a caller cannot depend on it, but a *test* can only pin
610/// bytes that are deterministic, so one order is chosen and held.
611fn shapeFix(allocator: std.mem.Allocator, fix: Fix) ![]u8 {
612 var out: std.ArrayListUnmanaged(u8) = .empty;
613 errdefer out.deinit(allocator);
614
615 try out.appendSlice(allocator, "{\"latitude\":");
616 try appendNumber(allocator, &out, fix.latitude);
617 try out.appendSlice(allocator, ",\"longitude\":");
618 try appendNumber(allocator, &out, fix.longitude);
619 try out.appendSlice(allocator, ",\"altitude\":");
620 try appendNumber(allocator, &out, fix.altitude);
621 // `accuracy`, not `horizontalAccuracy`: the desktop bridge uses the raw
622 // property name and is a different contract.
623 try out.appendSlice(allocator, ",\"accuracy\":");
624 try appendNumber(allocator, &out, fix.horizontal_accuracy);
625 try out.appendSlice(allocator, ",\"altitudeAccuracy\":");
626 try appendNumber(allocator, &out, fix.vertical_accuracy);
627 // `heading` is `-[CLLocation course]`, the direction of travel, and never
628 // a `CLHeading` compass reading — nothing in this path asks for one.
629 try out.appendSlice(allocator, ",\"heading\":");
630 try appendNumber(allocator, &out, fix.course);
631 try out.appendSlice(allocator, ",\"speed\":");
632 try appendNumber(allocator, &out, fix.speed);
633 try out.appendSlice(allocator, ",\"timestamp\":");
634 try appendNumber(allocator, &out, fix.timestamp_ms);
635 try out.append(allocator, '}');
636
637 return out.toOwnedSlice(allocator);
638}
639
640/// One `f64` as a JSON number.
641///
642/// `{d}` renders decimal notation, never scientific, and a finite `f64` can
643/// need up to `std.fmt.float.bufferSize(.decimal, f64)` (347) bytes — 1e300
644/// alone is 301 digits. This payload is eight doubles wide and a `[64]u8`
645/// anywhere in it would turn a legal reading into a `NoSpaceLeft` refusal.
646///
647/// Non-finite is refused rather than printed: `inf` and `nan` are not JSON, and
648/// Swift's own `JSONSerialization` refuses them too. A refusal reaches the page
649/// as a rejection; printing them would produce a syntax error inside the reply
650/// script with nothing to point at.
651fn appendNumber(
652 allocator: std.mem.Allocator,
653 out: *std.ArrayListUnmanaged(u8),
654 value: f64,
655) !void {
656 if (!std.math.isFinite(value)) return bridge_error.BridgeError.InvalidParameter;
657 var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
658 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
659}
660
661/// The `craftLocationError` detail: Swift's `["message": error.localizedDescription]`,
662/// which is `{"message":"…"}` on the wire. One key, no `code` and no `domain` —
663/// the `NSError` carries both and Swift puts neither on the bridge, so adding
664/// them would be a shape no page is written for.
665///
666/// The description is free text: locale-dependent, device-dependent, and
667/// occasionally quoting a file path back at you. `ios_events.formatEvent`
668/// inlines this detail into a JavaScript source position verbatim, so it is
669/// escaped with the shared `bridge_error.escapeJsonString` and never
670/// interpolated with a bare `{s}` — an unescaped `"` would close the string
671/// early and turn the whole `dispatchEvent` call into a syntax error in the
672/// page, with nothing to point at.
673///
674/// The scratch buffer is sized from the input rather than fixed: the worst case
675/// is six output bytes per input byte (a control byte becoming `\u001f`), and a
676/// fixed buffer would silently turn a long localised error into a dropped event.
677fn shapeLocationError(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
678 const scratch = try allocator.alloc(u8, message.len * 6 + 1);
679 defer allocator.free(scratch);
680
681 const escaped = try bridge_error.escapeJsonString(scratch, message);
682 return std.fmt.allocPrint(allocator, "", .{escaped});
683}
684
685// =============================================================================
686// The Info.plist gate, which stands in for two config flags Zig cannot see.
687// =============================================================================
688
689/// Which authorization Swift's `requestLocationAuthorization()` would ask for.
690const Authorization = enum { when_in_use, always };
691
692/// Emitted into Info.plist by `packages/ios/src/index.ts` iff
693/// `enableGeolocation || enableBackgroundLocation`.
694const key_when_in_use = "NSLocationWhenInUseUsageDescription";
695
696/// Emitted iff `enableBackgroundLocation` — the same condition that selects
697/// `requestAlwaysAuthorization` in Swift.
698const key_always = "NSLocationAlwaysAndWhenInUseUsageDescription";
699
700/// Decide, from evidence in the running process, whether geolocation was
701/// configured and which prompt to ask for.
702///
703/// Neither `config.enableGeolocation` nor `config.enableBackgroundLocation` has
704/// a Zig mirror — they appear nowhere in `packages/zig/src` outside a comment —
705/// which is the same gap `bridge_mobile_watch.zig` documents for
706/// `enableWatchApp`. Rather than guess, this reads the Info.plist keys the
707/// generator writes from exactly those flags. Apple requires the usage
708/// description before authorization may be requested at all, so its absence is
709/// both the honest signal that the app was never built for location and the
710/// condition under which requesting would be illegitimate; the refusal happens
711/// before any request is made, so what the system would otherwise do never
712/// arises.
713///
714/// Two divergences, both real and neither hideable:
715///
716/// - Swift rejects a disabled-geolocation call with `CAPABILITY_DISABLED`.
717/// `bridge_error.BridgeError` has no such member; `PermissionDenied` ->
718/// `PERMISSION_DENIED` is the nearest thing the protocol can say.
719/// - The when-in-use key is written for `enableGeolocation ||
720/// enableBackgroundLocation`, so the plist alone cannot tell those two
721/// flags apart. They cannot normally disagree: `packages/ios/src/index.ts`
722/// normalises the pair — `if (config.enableBackgroundLocation)
723/// config.enableGeolocation = true` — before it writes *both*
724/// `craft.config.json`, which is the file Swift decodes out of the bundle
725/// at runtime, and the Info.plist, so a generated app answers the same on
726/// both sides. The residual gap is a bundled `craft.config.json` edited by
727/// hand afterwards: Swift would read `enableGeolocation: false` and refuse
728/// where this gate, reading a plist nobody regenerated, opens. That
729/// widening runs in the direction the Info.plist already sanctions — the
730/// usage string exists, so the user sees a prompt the app declared — but it
731/// is still a widening.
732fn resolveAuthorization() !Authorization {
733 if (!is_darwin) return error.UnsupportedPlatform;
734
735 if (!try infoPlistHas(key_when_in_use)) {
736 // Not "getCurrentPosition refused": `watchPosition` takes this same
737 // gate, and a log line that names the wrong action sends whoever reads
738 // it to the wrong call site.
739 std.log.warn(
740 "location refused: Info.plist has no {s}, so this app was not built " ++
741 "with geolocation enabled",
742 .{key_when_in_use},
743 );
744 return bridge_error.BridgeError.PermissionDenied;
745 }
746
747 return if (try infoPlistHas(key_always)) .always else .when_in_use;
748}
749
750/// The main bundle's Info.plist value for `key`, or null when it has none.
751///
752/// Errors rather than answering null when the runtime itself will not
753/// cooperate: "there is no NSBundle class" and "this app did not ask for
754/// location" are different facts, and collapsing them would blame the app's
755/// configuration for a broken process.
756fn infoPlistValue(comptime key: [*:0]const u8) !Id {
757 if (!is_darwin) return error.UnsupportedPlatform;
758
759 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
760 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
761 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
762
763 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
764 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
765 return error.SelectorNotFound;
766 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
767
768 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
769 return error.SelectorNotFound;
770 return objc.msgSendId1(bundle, sel_lookup, ns_key);
771}
772
773/// Whether the main bundle's Info.plist carries `key`.
774fn infoPlistHas(comptime key: [*:0]const u8) !bool {
775 return (try infoPlistValue(key)) != null;
776}
777
778/// The array `configureBackgroundLocationIfNeeded()` needs to have been told
779/// about before `allowsBackgroundLocationUpdates` may be set to YES.
780const key_background_modes = "UIBackgroundModes";
781
782/// Whether this process declared the `location` background mode.
783///
784/// This is the *exact* precondition for `allowsBackgroundLocationUpdates = YES`:
785/// CoreLocation raises an exception when it is set in a process whose
786/// `UIBackgroundModes` lacks `location`, and an uncaught Objective-C exception
787/// is an uncatchable SIGABRT rather than an error to map. Reading the array
788/// itself tests the condition that actually throws.
789///
790/// It is also, in a generated app, exactly `config.enableBackgroundLocation`:
791/// `packages/ios/src/index.ts` writes the mode and
792/// `NSLocationAlwaysAndWhenInUseUsageDescription` from that one flag. Inferring
793/// it from the usage-description key instead would agree in every generated app
794/// and be a crash in a hand-edited plist that carries the string without the
795/// mode — which is the wrong direction to be wrong in.
796///
797/// A missing key is `false`, not an error: an app that declared no background
798/// modes is the ordinary case, and it is the same answer as
799/// `enableBackgroundLocation: false`.
800fn backgroundLocationDeclared() !bool {
801 if (!is_darwin) return error.UnsupportedPlatform;
802
803 const modes = (try infoPlistValue(key_background_modes)) orelse return false;
804
805 const sel_contains = objc.sel_registerName("containsObject:") orelse
806 return error.SelectorNotFound;
807 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse
808 return error.SelectorNotFound;
809
810 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
811 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
812
813 // A plist whose `UIBackgroundModes` is a string rather than an array is a
814 // broken plist; sending `containsObject:` to it is an unrecognised selector
815 // and therefore a SIGABRT. Asking first turns that into a `false`.
816 if (!responds(modes, sel_responds, sel_contains)) {
817 std.log.warn(
818 "watchPosition: {s} is present but does not respond to containsObject:; " ++
819 "treating background location as undeclared",
820 .{key_background_modes},
821 );
822 return false;
823 }
824
825 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
826 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
827 return error.SelectorNotFound;
828 const ns_location = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "location")) orelse
829 return error.NativeCallFailed;
830
831 const ContainsFn = *const fn (Id, Id, Id) callconv(.c) bool;
832 const contains: ContainsFn = @ptrCast(&objc.objc_msgSend);
833 return contains(modes, sel_contains, ns_location);
834}
835
836/// Set a `BOOL` property, but only if the receiver actually has it. Returns
837/// whether it was set, so a caller that cares can say so.
838///
839/// `allowsBackgroundLocationUpdates` is iOS 9+ and
840/// `showsBackgroundLocationIndicator` iOS 11+, and neither exists on the macOS
841/// `CLLocationManager` this file is also compiled against. An unrecognised
842/// selector is a SIGABRT rather than an error, so `respondsToSelector:` is the
843/// guard — a deployment-target comparison would be a claim about the build
844/// rather than a check on the object in hand.
845fn setBoolIfSupported(target: Id, sel_responds: Id, sel: Id, value: bool) bool {
846 if (!is_darwin) return false;
847
848 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
849 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
850 if (!responds(target, sel_responds, sel)) return false;
851
852 const SetFn = *const fn (Id, Id, bool) callconv(.c) void;
853 const set: SetFn = @ptrCast(&objc.objc_msgSend);
854 set(target, sel, value);
855 return true;
856}
857
858// =============================================================================
859// The delegate class, built at runtime.
860//
861// Follows `ios.zig`'s `installScriptMessageHandler` exactly: `objc_getClass`
862// first so a second call is a no-op, then `objc_allocateClassPair`,
863// `class_addMethod` with the right type encoding, `objc_registerClassPair`.
864// =============================================================================
865
866/// Deliberately *not* `"CraftLocationDelegate"`.
867///
868/// `bridge_location.zig` registers that name for the desktop, and it is
869/// compiled into this module's graph on a macOS host runner (`bridge.zig`
870/// imports `macos.zig`, which imports `bridge_location.zig`). Both files look
871/// the class up with `objc_getClass` first, so whichever registered second
872/// would silently adopt the other's class — and the other's IMPs, which read
873/// different properties and fire different events.
874const delegate_class_name = "CraftIOSLocationDelegate";
875
876/// The delegate instance, held for the life of the process.
877///
878/// `CLLocationManager` holds its delegate **weakly**; a released delegate is a
879/// crash the next time a fix arrives. This var is the strong reference.
880var delegate_instance: Id = null;
881
882/// The one `CLLocationManager` this module owns.
883///
884/// Module-level for the same reason as the delegate: a manager that goes out of
885/// scope stops delivering. One per process, created on first use from the main
886/// thread (dispatch runs synchronously from `craftDidReceiveScriptMessage`, a
887/// WebKit main-thread callback), which is also where CoreLocation wants it so
888/// the delegate callbacks land on the main run loop.
889var manager: Id = null;
890
891fn selector(name: [*:0]const u8) !Id {
892 if (!is_darwin) return error.UnsupportedPlatform;
893 return objc.sel_registerName(name) orelse error.SelectorNotFound;
894}
895
896/// Register the delegate class once, and keep one instance alive.
897fn ensureDelegate() !*anyopaque {
898 if (!is_darwin) return error.UnsupportedPlatform;
899 if (delegate_instance) |existing| return existing;
900
901 var cls = objc.objc_getClass(delegate_class_name);
902 if (cls == null) {
903 // Both selectors resolved before the class pair is allocated: a failure
904 // between `objc_allocateClassPair` and `objc_registerClassPair` leaves
905 // an unregistered class behind that nothing can dispose of.
906 const sel_update = try selector("locationManager:didUpdateLocations:");
907 const sel_fail = try selector("locationManager:didFailWithError:");
908
909 const NSObject = objc.objc_getClass("NSObject") orelse return error.ClassNotFound;
910 cls = objc.objc_allocateClassPair(NSObject, delegate_class_name, 0) orelse
911 return error.ClassAllocationFailed;
912
913 // v@:@@ — returns void, takes self, _cmd, the manager, and one object
914 // (the NSArray of CLLocation, or the NSError).
915 if (!objc.class_addMethod(cls, sel_update, @ptrCast(&didUpdateLocations), "v@:@@")) {
916 return error.MethodNotAdded;
917 }
918 if (!objc.class_addMethod(cls, sel_fail, @ptrCast(&didFailWithError), "v@:@@")) {
919 return error.MethodNotAdded;
920 }
921 objc.objc_registerClassPair(cls);
922 }
923
924 // `locationManagerDidChangeAuthorization:` is deliberately not added.
925 // Swift implements it purely to settle a pending `requestPermission`, which
926 // this module does not serve, and it emits nothing: no iOS page subscribes
927 // to any authorization event, so there is no `craft:location:authChanged`
928 // contract on this platform to honour. Adding the method would mean
929 // inventing one. CoreLocation dispatches through `respondsToSelector:`, so
930 // omitting it is legal, exactly as `ios.zig`'s handler declares no protocol.
931
932 const instance = (try objc.allocInit(cls)) orelse return error.NativeCallFailed;
933 delegate_instance = instance;
934 return instance;
935}
936
937/// `kCLLocationAccuracyBest`, if the symbol is in the process.
938///
939/// It is an `extern const CLLocationAccuracy` — a `double` variable — whose
940/// value is not in the header, so hardcoding one would be a guess. `dlsym` it
941/// out of whatever loaded CoreLocation, as `bridge_mobile_permissions.zig` does
942/// for `AVMediaTypeVideo`. A null answer is not a failure: Apple documents
943/// `desiredAccuracy`'s default as `kCLLocationAccuracyBest` already, so leaving
944/// the property alone lands on the same value Swift assigns.
945extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
946
947/// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin.
948const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
949
950fn bestAccuracy() ?f64 {
951 if (!is_darwin) return null;
952 const symbol = dlsym(RTLD_DEFAULT, "kCLLocationAccuracyBest") orelse return null;
953 const cell: *const f64 = @ptrCast(@alignCast(symbol));
954 return cell.*;
955}
956
957/// `CLActivityTypeFitness`. A header enum constant — `Other = 1`,
958/// `AutomotiveNavigation = 2`, `Fitness = 3` — so this one is safe to spell out.
959const cl_activity_type_fitness: c_long = 3;
960
961/// Build the manager once, configured the way `CraftApp.swift` configures its
962/// own at construction, with this module's delegate attached.
963///
964/// All three properties Swift sets are now load-bearing on this manager.
965/// `desiredAccuracy` governs the one-shot `requestLocation`; `activityType` and
966/// `pausesLocationUpdatesAutomatically` govern automatic pausing during the
967/// continuous updates `watchPosition` starts — an earlier revision of this
968/// comment called those two inert, which was true only while `watchPosition`
969/// belonged to the shim. `pausesLocationUpdatesAutomatically` is `false` because
970/// Swift sets it false: a manager that pauses itself stops calling the delegate
971/// and the page's watch callback simply goes quiet.
972fn ensureManager() !*anyopaque {
973 if (!is_darwin) return error.UnsupportedPlatform;
974 if (manager) |existing| return existing;
975
976 const CLLocationManager = objc.objc_getClass("CLLocationManager") orelse
977 return error.ClassNotFound;
978
979 // The delegate first: a manager that exists with no delegate would be
980 // cached by the line below and never deliver anything.
981 const delegate = try ensureDelegate();
982
983 const sel_set_delegate = try selector("setDelegate:");
984 const sel_activity = try selector("setActivityType:");
985 const sel_pauses = try selector("setPausesLocationUpdatesAutomatically:");
986
987 const created = (try objc.allocInit(CLLocationManager)) orelse return error.NativeCallFailed;
988
989 objc.msgSendVoid1(created, sel_set_delegate, delegate);
990
991 if (bestAccuracy()) |accuracy| {
992 const sel_accuracy = try selector("setDesiredAccuracy:");
993 const AccuracyFn = *const fn (Id, Id, f64) callconv(.c) void;
994 const set_accuracy: AccuracyFn = @ptrCast(&objc.objc_msgSend);
995 set_accuracy(created, sel_accuracy, accuracy);
996 } else {
997 // `ensureManager` runs for `watchPosition` too, so this is not
998 // `getCurrentPosition`'s line to claim.
999 std.log.info(
1000 "location: kCLLocationAccuracyBest is not in this process; " ++
1001 "leaving desiredAccuracy at its default, which Apple documents as the same value",
1002 .{},
1003 );
1004 }
1005
1006 const ActivityFn = *const fn (Id, Id, c_long) callconv(.c) void;
1007 const set_activity: ActivityFn = @ptrCast(&objc.objc_msgSend);
1008 set_activity(created, sel_activity, cl_activity_type_fitness);
1009
1010 const PausesFn = *const fn (Id, Id, bool) callconv(.c) void;
1011 const set_pauses: PausesFn = @ptrCast(&objc.objc_msgSend);
1012 set_pauses(created, sel_pauses, false);
1013
1014 manager = created;
1015 return created;
1016}
1017
1018// =============================================================================
1019// The selectors the delegate needs, resolved while an error is still deliverable.
1020// =============================================================================
1021
1022/// Everything `didUpdateLocations` will send, looked up at dispatch time.
1023///
1024/// The delegate runs after the dispatch frame is gone; a `sel_registerName`
1025/// failure in there could only be logged, or worse, turned into a rejection for
1026/// a fix that actually arrived. Resolving them here makes that class of failure
1027/// an ordinary synchronous error the page can see.
1028///
1029/// Both consumers capture a set: `getCurrentPosition` stores one on its
1030/// `PendingFix`, `watchPosition` stores one in `watch_sels`. The nine names are
1031/// the same either way — the split exists so that neither consumer's stream
1032/// depends on the other having run first, and so that a watch which outlives
1033/// every one-shot still has selectors of its own to shape events with.
1034const Sels = struct {
1035 last_object: Id,
1036 coordinate: Id,
1037 altitude: Id,
1038 horizontal_accuracy: Id,
1039 vertical_accuracy: Id,
1040 course: Id,
1041 speed: Id,
1042 timestamp: Id,
1043 time_interval_since_1970: Id,
1044
1045 fn resolve() !Sels {
1046 if (!is_darwin) return error.UnsupportedPlatform;
1047 return .{
1048 // Swift uses `locations.last`, so `lastObject` matches exactly and
1049 // returns nil for an empty array — no count/objectAtIndex:
1050 // arithmetic, and no chance of an off-by-one on the boundary.
1051 .last_object = try selector("lastObject"),
1052 .coordinate = try selector("coordinate"),
1053 .altitude = try selector("altitude"),
1054 .horizontal_accuracy = try selector("horizontalAccuracy"),
1055 .vertical_accuracy = try selector("verticalAccuracy"),
1056 .course = try selector("course"),
1057 .speed = try selector("speed"),
1058 .timestamp = try selector("timestamp"),
1059 .time_interval_since_1970 = try selector("timeIntervalSince1970"),
1060 };
1061 }
1062};
1063
1064// =============================================================================
1065// One native slot for the one-shot, one flag for the watch, one mutex for both.
1066//
1067// Swift keeps a single `singleLocationCallbackId`, and so does this: there is
1068// one manager and one delegate, and `didUpdateLocations` cannot tell two
1069// overlapping `requestLocation`s apart. The slot is guarded by a mutex even
1070// though CoreLocation delivers on the run loop that created the manager (the
1071// main one), because "it should always be the main thread" is not a guard.
1072//
1073// Displacement, not refusal, is the policy. Refusing a second call while one is
1074// pending would be tidier, but a slot that never clears — a fix that never
1075// arrives, an authorization prompt left unanswered — would then refuse every
1076// later call for the life of the process. Displacement cannot get stuck.
1077//
1078// What it costs: CoreLocation may report a cancelled `requestLocation` through
1079// `didFailWithError`, which would consume the *surviving* caller's ticket and
1080// reject it even though a fix follows. That is a wrong answer in the safe
1081// direction — an error where a reading was available — never a reading that is
1082// not real.
1083//
1084// The watch is the opposite kind of state and is stored differently for that
1085// reason. A one-shot is a *ticket* — one reply, consumed on the first callback,
1086// displaced by a newer caller. A watch is a *mode* — no reply after its initial
1087// `true`, answered by every callback, and idempotent to re-enter, because the
1088// page's own `Map` of callbacks is the thing that actually counts subscribers
1089// and it only ever posts on the empty-to-one and one-to-empty transitions.
1090// =============================================================================
1091
1092const PendingFix = struct {
1093 ticket: ios_async.Ticket,
1094 sels: Sels,
1095};
1096
1097var pending_fix: ?PendingFix = null;
1098
1099/// The selectors the running watch shapes its events with, or null when no
1100/// watch is running.
1101///
1102/// It doubles as Swift's `isWatchingLocation` flag, because the two are always
1103/// set and cleared together and a separate boolean could disagree with it. The
1104/// selectors are captured at dispatch for the same reason the one-shot's are:
1105/// `sel_registerName` failing inside a delegate callback could only be logged,
1106/// and a watch is a callback that runs for as long as the page is subscribed.
1107var watch_sels: ?Sels = null;
1108
1109/// Guards *both* of the above, deliberately.
1110///
1111/// One `didUpdateLocations` has to settle the one-shot and feed the watch, and
1112/// one `clearWatch` has to stop the watch and take the one-shot down with it.
1113/// Two locks would let those two pairs interleave; one lock makes each pair a
1114/// single decision. It is held even though CoreLocation delivers on the run loop
1115/// that created the manager, because "it should always be the main thread" is
1116/// not a guard.
1117var state_mutex: compat_mutex.Mutex = .{};
1118
1119/// Record the call the delegate will answer, returning whatever it displaced so
1120/// the caller can reject that one rather than drop it.
1121fn publishPendingFix(ticket: ios_async.Ticket, sels: Sels) ?PendingFix {
1122 state_mutex.lock();
1123 defer state_mutex.unlock();
1124 const displaced = pending_fix;
1125 pending_fix = .{ .ticket = ticket, .sels = sels };
1126 return displaced;
1127}
1128
1129/// Read and clear the slot. Clearing is what makes a second delegate call — a
1130/// late fix, a duplicate error — a no-op rather than a second reply.
1131fn takePendingFix() ?PendingFix {
1132 state_mutex.lock();
1133 defer state_mutex.unlock();
1134 const call = pending_fix;
1135 pending_fix = null;
1136 return call;
1137}
1138
1139/// Record the running watch. Overwriting is the whole behaviour: a second
1140/// `watchPosition` is idempotent in Swift, and the selectors are the same ones.
1141fn publishWatch(sels: Sels) void {
1142 state_mutex.lock();
1143 defer state_mutex.unlock();
1144 watch_sels = sels;
1145}
1146
1147/// What a delegate callback is answering: a one-shot, a watch, or both.
1148const DelegateWork = struct {
1149 /// Taken, and therefore cleared — a fix answers a one-shot exactly once.
1150 pending: ?PendingFix,
1151 /// Read, and deliberately *not* cleared — a fix feeds a watch every time,
1152 /// until `clearWatch` stops it. Clearing it here would deliver one event
1153 /// and then go silent with the page's callback still registered.
1154 watch: ?Sels,
1155};
1156
1157fn takeDelegateWork() DelegateWork {
1158 state_mutex.lock();
1159 defer state_mutex.unlock();
1160 const call = pending_fix;
1161 pending_fix = null;
1162 return .{ .pending = call, .watch = watch_sels };
1163}
1164
1165/// What `clearWatch` is stopping.
1166const WatchStop = struct {
1167 /// Whether a watch of this module's was running. False means
1168 /// `stopUpdatingLocation` is not called at all, which is why `pending` is
1169 /// then left alone.
1170 watching: bool,
1171 /// The one-shot that `stopUpdatingLocation` is about to cancel, so the
1172 /// caller can reject it. Null when there was none, and always null when
1173 /// nothing was stopped.
1174 pending: ?PendingFix,
1175};
1176
1177/// Stop the watch and take any one-shot with it, in one lock.
1178///
1179/// The two are one decision: CoreLocation documents `stopUpdatingLocation()` as
1180/// cancelling a pending `requestLocation()`, so whoever clears the watch is also
1181/// the one who has to settle the one-shot it just cancelled. Doing it in two
1182/// locks would leave a window in which a fix could arrive for a request that is
1183/// already dead.
1184///
1185/// When no watch was running nothing is stopped, so nothing is cancelled and the
1186/// one-shot is left strictly alone.
1187fn takeWatchAndPending() WatchStop {
1188 state_mutex.lock();
1189 defer state_mutex.unlock();
1190
1191 if (watch_sels == null) return .{ .watching = false, .pending = null };
1192 watch_sels = null;
1193
1194 const call = pending_fix;
1195 pending_fix = null;
1196 return .{ .watching = true, .pending = call };
1197}
1198
1199// =============================================================================
1200// The delegate methods.
1201//
1202// Neither replies directly: `evaluateJavaScript` is main-thread-only and these
1203// are framework callbacks, so the finished JSON goes to `ios_async.deliverJson`
1204// (or `deliverError`), which hops to the main queue and answers under the
1205// request id captured back at dispatch. Plain `fn`, never `export`: the desktop
1206// `bridge_location.zig` already exports `didUpdateLocations` and
1207// `didFailWithError` by those names, and it is in the same host-test binary.
1208// =============================================================================
1209
1210fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void {
1211 if (!is_darwin) return;
1212
1213 // One read: the one-shot is taken, the watch is left. See `DelegateWork`.
1214 const work = takeDelegateWork();
1215
1216 // Either consumer's selectors will do — they are the same nine names, and
1217 // `Sels.resolve()` is deterministic — but preferring the one-shot's keeps
1218 // the reply shaped from the selectors captured for *that* call.
1219 const sels = blk: {
1220 if (work.pending) |call| break :blk call.sels;
1221 if (work.watch) |watching| break :blk watching;
1222 // A fix for a call already answered, or one that arrived after the
1223 // watch stopped. Nothing to reply to and nobody subscribed.
1224 std.log.info("location: a fix arrived with no call waiting and no watch running; ignored", .{});
1225 return;
1226 };
1227
1228 const fix = readFix(locations, sels) catch |err| {
1229 reportUnusableFix(work.pending, "read the CLLocation", err);
1230 return;
1231 };
1232
1233 const allocator = std.heap.c_allocator;
1234 const json = shapeFix(allocator, fix) catch |err| {
1235 reportUnusableFix(work.pending, "shape the fix", err);
1236 return;
1237 };
1238 defer allocator.free(json);
1239
1240 // Swift's order, kept: `resolveCallback(singleLocationCallbackId, …)` runs
1241 // before `sendToWeb("craftLocationUpdate", …)`. Both of these hops target
1242 // `_dispatch_main_q`, which is serial, so enqueueing in this order is
1243 // delivering in this order.
1244 //
1245 // One shape, used twice, because Swift builds one dictionary and hands it
1246 // to both. A page reading `e.detail.accuracy` and a page reading
1247 // `position.accuracy` are reading the same eight keys.
1248 if (work.pending) |call| ios_async.deliverJson(call.ticket, json);
1249 if (work.watch != null) ios_events.emit(.location_update, json);
1250}
1251
1252fn didFailWithError(_: Id, _: Id, _: Id, err_object: Id) callconv(.c) void {
1253 if (!is_darwin) return;
1254
1255 // Read once and reused below: the log line is the one channel a
1256 // `BridgeError` enum leaves for a rejected caller, and the event is the
1257 // only channel a watch has at all.
1258 const description = readNSString(err_object, "localizedDescription") orelse "(none)";
1259
1260 std.log.warn(
1261 "location: CLLocationManager failed - domain={s} code={d} description={s}",
1262 .{
1263 readNSString(err_object, "domain") orelse "(none)",
1264 readNSInteger(err_object, "code"),
1265 description,
1266 },
1267 );
1268
1269 // Swift's order again: `rejectCallback(singleLocationCallbackId, …)` first,
1270 // then `sendToWeb("craftLocationError", …)`.
1271 if (takePendingFix()) |call| {
1272 ios_async.deliverError(call.ticket);
1273 } else {
1274 std.log.info("location: a CoreLocation error arrived with no one-shot waiting", .{});
1275 }
1276
1277 // Unconditional, exactly as Swift's `sendToWeb` here is: it fires whether or
1278 // not a callback was pending and whether or not a watch is running. A watch
1279 // that hits an error has no reply left to reject — its `true` went out when
1280 // it started — so conditioning the event on a pending call would make the
1281 // one consumer that needs it the one that never gets it.
1282 emitLocationError(description);
1283}
1284
1285/// Answer a fix that arrived and could not be used.
1286///
1287/// Two consumers, two consequences, and the log level follows the consequence
1288/// rather than the cause. A waiting `getCurrentPosition` is *rejected*: Swift
1289/// returns silently from `guard let location = locations.last else { return }`,
1290/// which on that untimed hand-built promise is a hang forever, so this is a real
1291/// failure being reported to a caller. A watch has nobody to reject — its `true`
1292/// went out when it started — and Swift emits nothing here either, so it is a
1293/// gap in a stream rather than a failed call.
1294///
1295/// What neither consumer gets is a position. No `craftLocationUpdate` is
1296/// emitted, because no reading was taken, and an event carrying a
1297/// plausible-looking fix that nothing measured is the exact fabrication this
1298/// migration exists to remove.
1299fn reportUnusableFix(pending: ?PendingFix, comptime what: []const u8, err: anyerror) void {
1300 if (pending) |call| {
1301 std.log.err(
1302 "getCurrentPosition: could not " ++ what ++ " ({}); rejecting rather than " ++
1303 "reporting a position that was not read",
1304 .{err},
1305 );
1306 ios_async.deliverError(call.ticket);
1307 } else {
1308 std.log.warn(
1309 "watchPosition: could not " ++ what ++ " ({}); no craftLocationUpdate is emitted " ++
1310 "for a position that was not read",
1311 .{err},
1312 );
1313 }
1314}
1315
1316/// Fire `craftLocationError` with the message Swift puts in it.
1317///
1318/// A failure to shape the detail drops the event rather than emitting a
1319/// half-built one: `ios_events.formatEvent` inlines the detail into JavaScript
1320/// source, so a malformed detail is a syntax error in the page rather than a
1321/// missing field. The drop is logged, because a silent one is indistinguishable
1322/// from no error having happened.
1323fn emitLocationError(message: []const u8) void {
1324 const allocator = std.heap.c_allocator;
1325
1326 const detail = shapeLocationError(allocator, message) catch |err| {
1327 std.log.warn(
1328 "location: could not shape the craftLocationError detail ({}); the event is dropped " ++
1329 "rather than sent malformed",
1330 .{err},
1331 );
1332 return;
1333 };
1334 defer allocator.free(detail);
1335
1336 ios_events.emit(.location_error, detail);
1337}
1338
1339/// `CLLocationCoordinate2D` — 16 bytes of two doubles.
1340///
1341/// Returned through the regular `objc_msgSend` on both arm64 and x86_64: the
1342/// `_stret` variant is for large structs and does not exist on arm64 at all.
1343/// `bridge_location.zig` writes out the same reasoning for the same struct.
1344const Coord = extern struct { latitude: f64, longitude: f64 };
1345
1346/// Read the last `CLLocation` out of the delegate's array.
1347///
1348/// `objc_msgSend_fpret` is not used and must not be: it is `long double`-only on
1349/// x86_64 and absent on arm64, so a `double`-returning property goes through the
1350/// plain symbol. `macos.zig`'s `msgSend0Double` does the same.
1351fn readFix(locations: Id, sels: Sels) !Fix {
1352 if (!is_darwin) return error.UnsupportedPlatform;
1353 if (locations == null) return error.NativeCallFailed;
1354
1355 const IdFn = *const fn (Id, Id) callconv(.c) Id;
1356 const send_id: IdFn = @ptrCast(&objc.objc_msgSend);
1357 const location = send_id(locations, sels.last_object) orelse return error.NoLocationInUpdate;
1358
1359 const CoordFn = *const fn (Id, Id) callconv(.c) Coord;
1360 const send_coord: CoordFn = @ptrCast(&objc.objc_msgSend);
1361 const coordinate = send_coord(location, sels.coordinate);
1362
1363 const DoubleFn = *const fn (Id, Id) callconv(.c) f64;
1364 const send_double: DoubleFn = @ptrCast(&objc.objc_msgSend);
1365
1366 const date = send_id(location, sels.timestamp) orelse return error.NativeCallFailed;
1367
1368 return .{
1369 .latitude = coordinate.latitude,
1370 .longitude = coordinate.longitude,
1371 .altitude = send_double(location, sels.altitude),
1372 // No `>= 0` filtering anywhere below: CoreLocation's -1 sentinels are
1373 // what Swift puts on the wire, and the page's types are written for
1374 // numbers rather than nulls.
1375 .horizontal_accuracy = send_double(location, sels.horizontal_accuracy),
1376 .vertical_accuracy = send_double(location, sels.vertical_accuracy),
1377 .course = send_double(location, sels.course),
1378 .speed = send_double(location, sels.speed),
1379 .timestamp_ms = send_double(date, sels.time_interval_since_1970) * 1000.0,
1380 };
1381}
1382
1383/// A zero-argument `NSString`-returning property, as bytes. Null for a nil
1384/// string or a selector that will not register — both are "nothing to report",
1385/// never a crash. The slice borrows the string's internal buffer, which is valid
1386/// for the current autorelease pool; every caller consumes it synchronously —
1387/// logged, or copied by `shapeLocationError` — before returning.
1388fn readNSString(object: Id, comptime name: [*:0]const u8) ?[]const u8 {
1389 if (!is_darwin) return null;
1390
1391 const sel = objc.sel_registerName(name) orelse return null;
1392 const value = objc.msgSendId(object, sel) orelse return null;
1393 const utf8 = objc.getNSStringUTF8(value) orelse return null;
1394 return std.mem.span(utf8);
1395}
1396
1397/// A zero-argument `NSInteger`-returning property.
1398fn readNSInteger(object: Id, comptime name: [*:0]const u8) c_long {
1399 if (!is_darwin) return 0;
1400
1401 const sel = objc.sel_registerName(name) orelse return 0;
1402 const Fn = *const fn (Id, Id) callconv(.c) c_long;
1403 const func: Fn = @ptrCast(&objc.objc_msgSend);
1404 return func(object, sel);
1405}
1406
1407// =============================================================================
1408// Tests - host-only.
1409//
1410// Everything that decides page-visible bytes is pure and pinned here: routing
1411// in both directions, the eight keys and their order, the three renamed keys,
1412// the -1 passthrough, non-finite refusal, a float buffer big enough for 1e300,
1413// the error event's one-key detail and its escaping, and — the part that is new
1414// and is the whole risk of serving a stream from the same delegate as a
1415// one-shot — how a fix, and a `clearWatch`, settle the two of them together.
1416//
1417// Nothing here constructs a `CLLocationManager` or requests authorization. On a
1418// macOS runner that would raise a real TCC location prompt against the test
1419// process, and unlike `WCSession` — which is `API_UNAVAILABLE(macos)` and so
1420// safely absent — CoreLocation exists on macOS and AppKit may well have loaded
1421// it, so `objc_getClass("CLLocationManager") == null` is not a safe assertion
1422// either. The Objective-C paths that *are* exercised for real are the ones with
1423// no device behind them: selector resolution, the Info.plist gate, and the
1424// delegate class registration.
1425// =============================================================================
1426
1427const testing = std.testing;
1428
1429test "the declared actions are the ones the handler serves" {
1430 try testing.expectEqual(@as(usize, 3), capability_actions.len);
1431 try testing.expectEqualStrings(A.get_current_position, capability_actions[0].name);
1432 try testing.expectEqualStrings(A.watch_position, capability_actions[1].name);
1433 try testing.expectEqualStrings(A.clear_watch, capability_actions[2].name);
1434
1435 for (capability_actions) |decl| {
1436 // A `.result` whose handler never replies parks the caller on an untimed
1437 // promise; a `.none` that is awaited resolves immediately and means
1438 // nothing. Both failure modes are invisible from the page. Swift
1439 // resolves all three of these, so all three are `.result` — including
1440 // `watchPosition`, whose `true` is about the subscribe having started,
1441 // not about a fix having arrived.
1442 try testing.expectEqual(capabilities.Reply.result, decl.reply);
1443 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1444 // `.live` with a reason would be a contradiction the manifest shows to apps.
1445 try testing.expect(decl.reason == null);
1446 }
1447}
1448
1449test "the action names match the Swift case labels exactly" {
1450 // The conformance ratchet compares these against the `case "…":` labels in
1451 // `CraftApp.swift` in both directions; a prettier spelling would read as
1452 // Zig serving an action the spec does not have.
1453 try testing.expectEqualStrings("getCurrentPosition", A.get_current_position);
1454 try testing.expectEqualStrings("watchPosition", A.watch_position);
1455 try testing.expectEqualStrings("clearWatch", A.clear_watch);
1456}
1457
1458test "the reply both subscribe actions send is the bare fragment Swift resolves" {
1459 // `resolveCallback(callbackId, result: true)` through `.fragmentsAllowed`.
1460 // `{"ok":true}` would break `if (await craft._invoke('watchPosition'))`
1461 // nowhere and `{"watchId":…}` would invent an id the native side does not
1462 // have — but `craft-bridge.js` resolves with `payload || {}`, so the one
1463 // thing that genuinely matters is that this stays truthy.
1464 try testing.expectEqualStrings("true", true_fragment);
1465}
1466
1467test "every declared action is one the dispatcher routes" {
1468 for (capability_actions) |decl| {
1469 if (routeFor(decl.name) == null) {
1470 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
1471 return error.DeclaredActionDoesNotRoute;
1472 }
1473 }
1474}
1475
1476test "every route the dispatcher has is a declared action" {
1477 // The other direction: a route with a handler and no declaration is an
1478 // action the page can call and the manifest denies exists. Each declaration
1479 // must claim a *distinct* route — counting alone would let two rows share
1480 // one route while another went undeclared.
1481 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
1482 for (capability_actions) |decl| {
1483 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
1484 const slot = @backingInt(route);
1485 if (claimed[slot]) {
1486 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
1487 return error.TwoDeclarationsShareARoute;
1488 }
1489 claimed[slot] = true;
1490 }
1491 for (claimed, 0..) |taken, slot| {
1492 if (!taken) {
1493 std.debug.print(
1494 "route {s} has a handler but no capability_actions row\n",
1495 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
1496 );
1497 return error.RouteNotDeclared;
1498 }
1499 }
1500}
1501
1502test "watchPosition and clearWatch route to their own handlers" {
1503 try testing.expectEqual(Route.current_position, routeFor("getCurrentPosition").?);
1504 try testing.expectEqual(Route.watch_position, routeFor("watchPosition").?);
1505 try testing.expectEqual(Route.clear_watch, routeFor("clearWatch").?);
1506
1507 // Casing is how a real typo arrives, and a miss here does not fail loudly:
1508 // `ios_dispatch` reads UnknownAction as "not mine" and hands the action to
1509 // the Swift shim, so a typo would silently run two implementations of the
1510 // same watch against two managers.
1511 for ([_][]const u8{ "watchposition", "WatchPosition", "clearwatch", "ClearWatch" }) |typo| {
1512 try testing.expect(routeFor(typo) == null);
1513 }
1514}
1515
1516test "the stream is emitted on the name an iOS page's addEventListener names" {
1517 // The single fact that kept these two actions with the shim until now, and
1518 // the single fact that could silently un-serve them again.
1519 //
1520 // Both subscribers in the injected JS are
1521 // `window.addEventListener('craftLocationUpdate', …)`: the legacy
1522 // `geolocation.watchPosition`, and the v1 `craft.location` wrapper's shared
1523 // dispatcher. A repo-wide grep finds no iOS subscriber to any other
1524 // spelling. `ios_events.Event` carries that vocabulary;
1525 // `capabilities.Channel` carries the desktop's, and emitting the desktop
1526 // spelling would resolve `true` and then feed the page's callback nothing
1527 // forever — a subscription that reports success and never delivers.
1528 try testing.expectEqualStrings(
1529 "craftLocationUpdate",
1530 ios_events.Event.location_update.eventName(),
1531 );
1532 try testing.expectEqualStrings(
1533 "craftLocationError",
1534 ios_events.Event.location_error.eventName(),
1535 );
1536
1537 // Pinned as the thing they must *not* be, so that a well-meaning
1538 // consolidation onto `capabilities.Channel` fails here rather than on a
1539 // device.
1540 try testing.expectEqualStrings(
1541 "craft:location:update",
1542 capabilities.Channel.location_update.eventName(),
1543 );
1544 try testing.expect(!std.mem.eql(
1545 u8,
1546 ios_events.Event.location_update.eventName(),
1547 capabilities.Channel.location_update.eventName(),
1548 ));
1549}
1550
1551test "an action the namespace does not serve is reported, not ignored" {
1552 var bridge = LocationBridge.init(testing.allocator);
1553 defer bridge.deinit();
1554
1555 try testing.expectError(
1556 bridge_error.BridgeError.UnknownAction,
1557 bridge.handleMessage("noSuchAction", "{}"),
1558 );
1559 // Casing is how a real typo arrives.
1560 try testing.expectError(
1561 bridge_error.BridgeError.UnknownAction,
1562 bridge.handleMessage("getcurrentposition", "{}"),
1563 );
1564 // The neighbouring recording actions belong to another module; two modules
1565 // answering one action would make `ios_dispatch`'s first-match routing
1566 // order-dependent.
1567 for ([_][]const u8{
1568 "startLocationRecording",
1569 "pauseLocationRecording",
1570 "resumeLocationRecording",
1571 "stopLocationRecording",
1572 "getLocationRecordingState",
1573 "readLocationRecording",
1574 }) |action| {
1575 try testing.expectError(
1576 bridge_error.BridgeError.UnknownAction,
1577 bridge.handleMessage(action, "{}"),
1578 );
1579 }
1580 // `requestPermission('location')` is a permissions action, not this one,
1581 // and it is still shim-served — see the cross-action note in the module
1582 // comment.
1583 try testing.expectError(
1584 bridge_error.BridgeError.UnknownAction,
1585 bridge.handleMessage("requestPermission", "{\"permission\":\"location\"}"),
1586 );
1587}
1588
1589// -----------------------------------------------------------------------------
1590// Reply shaping.
1591// -----------------------------------------------------------------------------
1592
1593/// A JSON number as an `f64`, whatever variant `std.json` chose for it.
1594///
1595/// Needed because the variant is a property of the *bytes*, not of the value:
1596/// `{d}` renders 65 as the integer token `65` (`.integer`), 3.25 as `3.25`
1597/// (`.float`), and 1e300 as 301 digits with no decimal point — an integer token
1598/// that overflows `i64` and lands in `.number_string`. Reading `.float`
1599/// directly, as the first draft of these tests did, panics on two of the three.
1600fn numberOf(value: std.json.Value) !f64 {
1601 return switch (value) {
1602 .integer => |i| @floatFromInt(i),
1603 .float => |f| f,
1604 .number_string => |text| try std.fmt.parseFloat(f64, text),
1605 else => error.ReplyFieldIsNotANumber,
1606 };
1607}
1608
1609/// A fix whose eight doubles are all different, so no test below can pass with
1610/// two of them transposed.
1611const sample_fix = Fix{
1612 .latitude = 37.7749,
1613 .longitude = -122.4194,
1614 .altitude = 12.5,
1615 .horizontal_accuracy = 65,
1616 .vertical_accuracy = 3.25,
1617 .course = 180.5,
1618 .speed = 1.75,
1619 .timestamp_ms = 1700000000123.5,
1620};
1621
1622test "the reply is the eight-key object Swift resolves, in a fixed order" {
1623 // Swift builds a `[String: Any]` and resolves it through
1624 // `.fragmentsAllowed`, which permits a bare value but never unwraps a
1625 // dictionary into one. A bare number, an array, or a `{position: …}`
1626 // wrapper would all break `result.latitude` in every app.
1627 const json = try shapeFix(testing.allocator, sample_fix);
1628 defer testing.allocator.free(json);
1629
1630 try testing.expectEqualStrings(
1631 "{\"latitude\":37.7749,\"longitude\":-122.4194,\"altitude\":12.5," ++
1632 "\"accuracy\":65,\"altitudeAccuracy\":3.25,\"heading\":180.5," ++
1633 "\"speed\":1.75,\"timestamp\":1700000000123.5}",
1634 json,
1635 );
1636
1637 // And it parses as the object it claims to be, with exactly eight keys.
1638 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1639 defer parsed.deinit();
1640 try testing.expectEqual(@as(usize, 8), parsed.value.object.count());
1641}
1642
1643test "accuracy, altitudeAccuracy and heading carry the sources Swift gave them" {
1644 // The three renames, and the only three places this module can silently
1645 // disagree with the spec: `accuracy` is `horizontalAccuracy`,
1646 // `altitudeAccuracy` is `verticalAccuracy`, and `heading` is `course` —
1647 // the direction of travel, not a compass reading. Transposing any pair
1648 // produces a reply that still parses and is still wrong.
1649 const json = try shapeFix(testing.allocator, sample_fix);
1650 defer testing.allocator.free(json);
1651
1652 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1653 defer parsed.deinit();
1654 const object = parsed.value.object;
1655
1656 try testing.expectEqual(sample_fix.horizontal_accuracy, try numberOf(object.get("accuracy").?));
1657 try testing.expectEqual(sample_fix.vertical_accuracy, try numberOf(object.get("altitudeAccuracy").?));
1658 try testing.expectEqual(sample_fix.course, try numberOf(object.get("heading").?));
1659 try testing.expectEqual(sample_fix.latitude, try numberOf(object.get("latitude").?));
1660 try testing.expectEqual(sample_fix.longitude, try numberOf(object.get("longitude").?));
1661 try testing.expectEqual(sample_fix.altitude, try numberOf(object.get("altitude").?));
1662 try testing.expectEqual(sample_fix.speed, try numberOf(object.get("speed").?));
1663 try testing.expectEqual(sample_fix.timestamp_ms, try numberOf(object.get("timestamp").?));
1664
1665 // The desktop `bridge_location.zig` emits the raw property names. They are
1666 // a different contract, and a copy-paste from it would put them here.
1667 try testing.expect(std.mem.indexOf(u8, json, "horizontalAccuracy") == null);
1668 try testing.expect(std.mem.indexOf(u8, json, "verticalAccuracy") == null);
1669 // Nor is there a watch id anywhere: this action returns a position.
1670 try testing.expect(std.mem.indexOf(u8, json, "watchId") == null);
1671}
1672
1673test "CoreLocation's -1 sentinels are passed through, not nulled" {
1674 // Swift's dictionary has no nil checks, no `>= 0` guards and no coalescing,
1675 // so an invalid reading reaches the page as -1. `Location.accuracy` is
1676 // typed `number`, not `number | null`, and a page doing arithmetic on it
1677 // would get NaN from a null.
1678 const json = try shapeFix(testing.allocator, .{
1679 .latitude = 0,
1680 .longitude = 0,
1681 .altitude = 0,
1682 .horizontal_accuracy = -1,
1683 .vertical_accuracy = -1,
1684 .course = -1,
1685 .speed = -1,
1686 .timestamp_ms = 0,
1687 });
1688 defer testing.allocator.free(json);
1689
1690 try testing.expectEqualStrings(
1691 "{\"latitude\":0,\"longitude\":0,\"altitude\":0,\"accuracy\":-1," ++
1692 "\"altitudeAccuracy\":-1,\"heading\":-1,\"speed\":-1,\"timestamp\":0}",
1693 json,
1694 );
1695
1696 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1697 defer parsed.deinit();
1698 // Numbers, not nulls, and the number is -1 — the assertion the wording
1699 // above is about.
1700 for ([_][]const u8{ "accuracy", "altitudeAccuracy", "heading", "speed" }) |key| {
1701 const value = parsed.value.object.get(key).?;
1702 try testing.expect(value != .null);
1703 try testing.expectEqual(@as(f64, -1), try numberOf(value));
1704 }
1705}
1706
1707test "the timestamp keeps its fraction rather than being truncated to a millisecond" {
1708 // `timeIntervalSince1970 * 1000` is a fractional double. Rounding it would
1709 // be a quiet change to a value pages use to order samples.
1710 const json = try shapeFix(testing.allocator, .{
1711 .latitude = 1,
1712 .longitude = 2,
1713 .altitude = 3,
1714 .horizontal_accuracy = 4,
1715 .vertical_accuracy = 5,
1716 .course = 6,
1717 .speed = 7,
1718 .timestamp_ms = 1700000000123.456,
1719 });
1720 defer testing.allocator.free(json);
1721
1722 try testing.expect(std.mem.indexOf(u8, json, "\"timestamp\":1700000000123.456") != null);
1723}
1724
1725test "a non-finite reading is refused in every field, not printed" {
1726 // `inf` and `nan` are not JSON. Printing either would produce a syntax
1727 // error inside the reply script with nothing to point at; Swift's own
1728 // `JSONSerialization` refuses them too. Each field is checked separately,
1729 // because one unguarded `appendNumber` call site is all it takes.
1730 const fields = [_][]const u8{
1731 "latitude", "longitude", "altitude", "horizontal_accuracy",
1732 "vertical_accuracy", "course", "speed", "timestamp_ms",
1733 };
1734 for (fields, 0..) |name, index| {
1735 for ([_]f64{ std.math.inf(f64), -std.math.inf(f64), std.math.nan(f64) }) |bad| {
1736 var fix = sample_fix;
1737 switch (index) {
1738 0 => fix.latitude = bad,
1739 1 => fix.longitude = bad,
1740 2 => fix.altitude = bad,
1741 3 => fix.horizontal_accuracy = bad,
1742 4 => fix.vertical_accuracy = bad,
1743 5 => fix.course = bad,
1744 6 => fix.speed = bad,
1745 7 => fix.timestamp_ms = bad,
1746 else => unreachable,
1747 }
1748 if (shapeFix(testing.allocator, fix)) |json| {
1749 testing.allocator.free(json);
1750 std.debug.print("field '{s}' accepted a non-finite value\n", .{name});
1751 return error.NonFiniteAccepted;
1752 } else |err| {
1753 try testing.expectEqual(bridge_error.BridgeError.InvalidParameter, err);
1754 }
1755 }
1756 }
1757}
1758
1759test "an extreme magnitude keeps its digits instead of overflowing the buffer" {
1760 // `{d}` renders decimal, never scientific: 1e300 is 301 digits, and the
1761 // payload carries eight doubles. A `[64]u8` anywhere in `appendNumber`
1762 // would turn a legal reading into a NoSpaceLeft refusal — which the page
1763 // would see as a rejected position request.
1764 const json = try shapeFix(testing.allocator, .{
1765 .latitude = 1e300,
1766 .longitude = -1e300,
1767 .altitude = 1e-300,
1768 .horizontal_accuracy = std.math.floatMax(f64),
1769 .vertical_accuracy = -std.math.floatMax(f64),
1770 .course = std.math.floatMin(f64),
1771 .speed = 0,
1772 .timestamp_ms = 1e300,
1773 });
1774 defer testing.allocator.free(json);
1775
1776 try testing.expect(json.len > 1500);
1777 try testing.expect(std.mem.indexOf(u8, json, "e+") == null);
1778 try testing.expect(std.mem.indexOf(u8, json, "E") == null);
1779
1780 // Still valid JSON, and the largest value survived intact rather than
1781 // being clipped at a buffer boundary.
1782 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1783 defer parsed.deinit();
1784 try testing.expectEqual(@as(usize, 8), parsed.value.object.count());
1785 try testing.expectEqual(@as(f64, 1e300), try numberOf(parsed.value.object.get("latitude").?));
1786 try testing.expectEqual(
1787 std.math.floatMax(f64),
1788 try numberOf(parsed.value.object.get("accuracy").?),
1789 );
1790}
1791
1792test "the watch event and the one-shot reply are the same bytes" {
1793 // `didUpdateLocations` shapes once and uses the result twice — `deliverJson`
1794 // for a pending one-shot, `ios_events.emit` for the watch — because Swift
1795 // builds one `[String: Any]` and hands it to both `resolveCallback` and
1796 // `sendToWeb`. A page doing `craft.location.watchPosition(p => …)` and a
1797 // page doing `craft.location.getCurrentPosition()` read the same eight keys,
1798 // so a second shaping function here would be two contracts wearing one name.
1799 const json = try shapeFix(testing.allocator, sample_fix);
1800 defer testing.allocator.free(json);
1801
1802 // The detail is inlined as a JSON literal by `ios_events.formatEvent`, not
1803 // quoted, so these bytes are what `e.detail` parses to.
1804 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1805 defer parsed.deinit();
1806 try testing.expectEqual(@as(usize, 8), parsed.value.object.count());
1807 for ([_][]const u8{
1808 "latitude", "longitude", "altitude", "accuracy",
1809 "altitudeAccuracy", "heading", "speed", "timestamp",
1810 }) |key| {
1811 try testing.expect(parsed.value.object.get(key) != null);
1812 }
1813}
1814
1815// -----------------------------------------------------------------------------
1816// The error event's detail.
1817// -----------------------------------------------------------------------------
1818
1819test "the error event carries Swift's one-key message object" {
1820 // `sendToWeb("craftLocationError", data: ["message": error.localizedDescription])`.
1821 // One key. The NSError's `domain` and `code` are logged here but are not on
1822 // the wire, because Swift does not put them there.
1823 const json = try shapeLocationError(testing.allocator, "The operation could not be completed.");
1824 defer testing.allocator.free(json);
1825
1826 try testing.expectEqualStrings(
1827 "{\"message\":\"The operation could not be completed.\"}",
1828 json,
1829 );
1830
1831 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1832 defer parsed.deinit();
1833 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
1834 try testing.expect(parsed.value.object.get("code") == null);
1835 try testing.expect(parsed.value.object.get("domain") == null);
1836}
1837
1838test "a description carrying quotes, backslashes or control bytes stays inside its string" {
1839 // `ios_events.formatEvent` inlines this detail into a JavaScript source
1840 // position verbatim. An unescaped `"` closes the string early and turns the
1841 // whole `dispatchEvent(...)` call into a syntax error in the page, with
1842 // nothing to point at — and an NSError's `localizedDescription` is
1843 // device- and locale-controlled text that quotes file paths back at you.
1844 const raw = "a \"b\" \\ c\nd\te\x01";
1845 const json = try shapeLocationError(testing.allocator, raw);
1846 defer testing.allocator.free(json);
1847
1848 try testing.expectEqualStrings(
1849 "{\"message\":\"a \\\"b\\\" \\\\ c\\nd\\te\\u0001\"}",
1850 json,
1851 );
1852
1853 // And it round-trips: the page receives the description Swift would have
1854 // sent, not an escaped rendering of it.
1855 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1856 defer parsed.deinit();
1857 try testing.expectEqualStrings(raw, parsed.value.object.get("message").?.string);
1858}
1859
1860test "an empty and a very long description are both shaped rather than dropped" {
1861 const empty = try shapeLocationError(testing.allocator, "");
1862 defer testing.allocator.free(empty);
1863 try testing.expectEqualStrings("{\"message\":\"\"}", empty);
1864
1865 // Worst case is six output bytes per input byte. A fixed scratch buffer
1866 // would turn a long localised error into a `BufferTooSmall`, and
1867 // `emitLocationError` drops an event it cannot shape — so the page would
1868 // lose exactly the errors that had the most to say.
1869 const long = try testing.allocator.alloc(u8, 4096);
1870 defer testing.allocator.free(long);
1871 @memset(long, 0x01);
1872
1873 const json = try shapeLocationError(testing.allocator, long);
1874 defer testing.allocator.free(json);
1875
1876 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1877 defer parsed.deinit();
1878 try testing.expectEqual(@as(usize, 4096), parsed.value.object.get("message").?.string.len);
1879}
1880
1881test "the float buffer is sized from the formatter, not from a guess" {
1882 // The constant this module depends on, asserted rather than assumed: a
1883 // decimal f64 needs 347 bytes in the worst case, and the sibling modules
1884 // that guessed 64 truncated.
1885 try testing.expect(std.fmt.float.bufferSize(.decimal, f64) > 300);
1886}
1887
1888// -----------------------------------------------------------------------------
1889// The single-fix slot, the watch flag, and the three ways they interact.
1890//
1891// `takeWatchAndPending()` is used as the reset between tests as well as being
1892// the thing under test: it is the only function that clears both, and using a
1893// test-only clearer would let production and test disagree about what "clear"
1894// means.
1895// -----------------------------------------------------------------------------
1896
1897const empty_sels = Sels{
1898 .last_object = null,
1899 .coordinate = null,
1900 .altitude = null,
1901 .horizontal_accuracy = null,
1902 .vertical_accuracy = null,
1903 .course = null,
1904 .speed = null,
1905 .timestamp = null,
1906 .time_interval_since_1970 = null,
1907};
1908
1909test "the slot hands the delegate its ticket once, and clearing it stops a second reply" {
1910 // The delegate methods are plain C functions with no capture, so this slot
1911 // is the only way either of them learns which call it is answering. Two
1912 // properties matter: a published call comes back with its generation intact
1913 // (a stale generation makes `deliverJson` a silent no-op), and taking it
1914 // clears the entry, so a double-firing delegate cannot reply twice.
1915 try testing.expect(takePendingFix() == null);
1916
1917 const ticket = ios_async.Ticket{ .index = 4, .generation = 91 };
1918 try testing.expect(publishPendingFix(ticket, empty_sels) == null);
1919
1920 const taken = takePendingFix() orelse return error.PublishedCallWentMissing;
1921 try testing.expectEqual(@as(u5, 4), taken.ticket.index);
1922 try testing.expectEqual(@as(u32, 91), taken.ticket.generation);
1923
1924 try testing.expect(takePendingFix() == null);
1925}
1926
1927test "a second call displaces the first and hands it back to be rejected" {
1928 // Swift overwrites `singleLocationCallbackId` and the displaced promise
1929 // never settles. Returning the old entry is what lets the handler reject it
1930 // instead — the caller must be able to see what it evicted, or the eviction
1931 // is a silent drop.
1932 try testing.expect(takePendingFix() == null);
1933
1934 const first = ios_async.Ticket{ .index = 1, .generation = 2 };
1935 const second = ios_async.Ticket{ .index = 3, .generation = 4 };
1936
1937 try testing.expect(publishPendingFix(first, empty_sels) == null);
1938
1939 const displaced = publishPendingFix(second, empty_sels) orelse
1940 return error.DisplacedCallWasDroppedSilently;
1941 try testing.expectEqual(@as(u5, 1), displaced.ticket.index);
1942 try testing.expectEqual(@as(u32, 2), displaced.ticket.generation);
1943
1944 // The survivor is the newer one, and it is still there to be answered.
1945 const remaining = takePendingFix() orelse return error.SurvivingCallWentMissing;
1946 try testing.expectEqual(@as(u5, 3), remaining.ticket.index);
1947 try testing.expectEqual(@as(u32, 4), remaining.ticket.generation);
1948}
1949
1950test "a delegate callback with no call recorded does nothing to the slot" {
1951 // A late or duplicate fire must not reach `deliverJson`/`deliverError` at
1952 // all. With the slot empty there is no ticket to reply with, and both
1953 // methods return without touching the pool — asserted by the slot staying
1954 // empty rather than by observing a reply that must not happen.
1955 //
1956 // `didFailWithError` does still fire `craftLocationError` here, exactly as
1957 // Swift's does with a nil `singleLocationCallbackId`: the event is not
1958 // conditioned on a caller. What it must not do is take a slot.
1959 _ = takeWatchAndPending();
1960 try testing.expect(takePendingFix() == null);
1961
1962 didUpdateLocations(null, null, null, null);
1963 try testing.expect(takePendingFix() == null);
1964
1965 didFailWithError(null, null, null, null);
1966 try testing.expect(takePendingFix() == null);
1967
1968 // And neither of them started a watch.
1969 try testing.expect(!takeWatchAndPending().watching);
1970}
1971
1972test "one fix settles the one-shot and feeds the watch, consuming only the one-shot" {
1973 // The interaction this whole change turns on. A `getCurrentPosition` and a
1974 // `watchPosition` can be live at the same time, one `didUpdateLocations`
1975 // has to serve both, and the two halves behave differently: the reply is
1976 // answered once, the stream is answered every time.
1977 //
1978 // Reading them under one lock is what makes that one decision. Two locks
1979 // would leave a window where a `clearWatch` could land between taking the
1980 // fix and reading the watch, and the fix would then be replied to for a
1981 // subscription that had already been told it was stopped.
1982 _ = takeWatchAndPending();
1983 try testing.expect(takePendingFix() == null);
1984
1985 const ticket = ios_async.Ticket{ .index = 7, .generation = 11 };
1986 try testing.expect(publishPendingFix(ticket, empty_sels) == null);
1987 publishWatch(empty_sels);
1988
1989 const first = takeDelegateWork();
1990 const pending = first.pending orelse return error.OneShotWasNotHandedToTheDelegate;
1991 try testing.expectEqual(@as(u5, 7), pending.ticket.index);
1992 try testing.expectEqual(@as(u32, 11), pending.ticket.generation);
1993 try testing.expect(first.watch != null);
1994
1995 // The second fix of the same subscription: no one-shot left to answer — a
1996 // double reply is a settled promise being settled again — and the watch
1997 // still there, because a stream that stops after one event is a page whose
1998 // callback is registered and silent.
1999 const second = takeDelegateWork();
2000 try testing.expect(second.pending == null);
2001 try testing.expect(second.watch != null);
2002
2003 _ = takeWatchAndPending();
2004}
2005
2006test "clearing a running watch settles the one-shot it just cancelled" {
2007 // CoreLocation documents `stopUpdatingLocation()` as cancelling a pending
2008 // `requestLocation()`, so the fix that would have answered an in-flight
2009 // `getCurrentPosition` is not coming. Its promise is the untimed
2010 // hand-built kind, so leaving it there is a page parked forever, not a page
2011 // that waits thirty seconds. `clearWatch` has to be handed that call in
2012 // order to reject it — if this returned null the rejection could not
2013 // happen, and the drop would be silent.
2014 _ = takeWatchAndPending();
2015 try testing.expect(takePendingFix() == null);
2016
2017 publishWatch(empty_sels);
2018 const ticket = ios_async.Ticket{ .index = 2, .generation = 5 };
2019 try testing.expect(publishPendingFix(ticket, empty_sels) == null);
2020
2021 const stop = takeWatchAndPending();
2022 try testing.expect(stop.watching);
2023 const stranded = stop.pending orelse return error.PendingFixWasStrandedByClearWatch;
2024 try testing.expectEqual(@as(u5, 2), stranded.ticket.index);
2025 try testing.expectEqual(@as(u32, 5), stranded.ticket.generation);
2026
2027 // Cleared, so a fix that races the stop cannot reply to the ticket
2028 // `clearWatch` has already rejected.
2029 try testing.expect(takePendingFix() == null);
2030 try testing.expect(!takeWatchAndPending().watching);
2031}
2032
2033test "clearing a watch that is not running leaves a pending one-shot alone" {
2034 // The deliberate divergence. Swift's `stopWatchingPosition()` calls
2035 // `stopUpdatingLocation()` unconditionally, so in Swift a `clearWatch` with
2036 // no watch running still cancels an in-flight `getCurrentPosition`. This
2037 // module only stops what it started, so nothing is cancelled and the
2038 // one-shot survives to be answered.
2039 //
2040 // Getting this wrong is invisible in the shape of the reply and visible
2041 // only as a position request that fails whenever the page happens to
2042 // unmount a map component at the same moment.
2043 _ = takeWatchAndPending();
2044
2045 const ticket = ios_async.Ticket{ .index = 9, .generation = 3 };
2046 try testing.expect(publishPendingFix(ticket, empty_sels) == null);
2047
2048 const stop = takeWatchAndPending();
2049 try testing.expect(!stop.watching);
2050 try testing.expect(stop.pending == null);
2051
2052 const survivor = takePendingFix() orelse return error.IdleClearWatchTookTheOneShot;
2053 try testing.expectEqual(@as(u5, 9), survivor.ticket.index);
2054 try testing.expectEqual(@as(u32, 3), survivor.ticket.generation);
2055}
2056
2057test "a fix that cannot be read leaves the watch running and reports nothing" {
2058 // `readFix` refuses a null `locations` — Swift's `guard let location =
2059 // locations.last else { return }`. Two things must hold: no
2060 // `craftLocationUpdate` is emitted, because no reading was taken and a
2061 // plausible-looking event is the fabrication this migration exists to
2062 // remove; and the subscription survives, because one unreadable callback is
2063 // not the end of a stream.
2064 _ = takeWatchAndPending();
2065 publishWatch(empty_sels);
2066
2067 didUpdateLocations(null, null, null, null);
2068
2069 const after = takeWatchAndPending();
2070 try testing.expect(after.watching);
2071 try testing.expect(after.pending == null);
2072}
2073
2074test "re-entering watchPosition is idempotent rather than additive" {
2075 // The page posts `watchPosition` on the empty-to-one transition of its own
2076 // callback `Map`, but a `clearWatch` racing a new subscriber can produce a
2077 // second post. Swift re-sets its flag and re-issues `startUpdatingLocation`;
2078 // there is one native watch either way, and one `clearWatch` stops it.
2079 _ = takeWatchAndPending();
2080
2081 publishWatch(empty_sels);
2082 publishWatch(empty_sels);
2083
2084 try testing.expect(takeWatchAndPending().watching);
2085 try testing.expect(!takeWatchAndPending().watching);
2086}
2087
2088// -----------------------------------------------------------------------------
2089// Platform behaviour.
2090// -----------------------------------------------------------------------------
2091
2092test "off Darwin the handler refuses rather than invent a position" {
2093 if (is_darwin) return error.SkipZigTest;
2094
2095 var bridge = LocationBridge.init(testing.allocator);
2096 defer bridge.deinit();
2097
2098 // A zeroed position would be a perfectly plausible-looking lie: (0, 0) is
2099 // a real coordinate, and a page cannot tell it from a reading. A `true`
2100 // from `watchPosition` or `clearWatch` would be the same lie about a
2101 // subscription that is not running and a stop that did not happen.
2102 for ([_][]const u8{ A.get_current_position, A.watch_position, A.clear_watch }) |action| {
2103 try testing.expectError(error.UnsupportedPlatform, bridge.handleMessage(action, "{}"));
2104 }
2105 try testing.expectError(error.UnsupportedPlatform, resolveAuthorization());
2106 try testing.expectError(error.UnsupportedPlatform, Sels.resolve());
2107 try testing.expectError(error.UnsupportedPlatform, ensureManager());
2108 try testing.expectError(error.UnsupportedPlatform, ensureDelegate());
2109 try testing.expectError(error.UnsupportedPlatform, backgroundLocationDeclared());
2110}
2111
2112test "without the usage description the gate refuses before any manager exists" {
2113 if (!is_darwin) return error.SkipZigTest;
2114
2115 // The host test runner is a bare binary with no Info.plist, which is
2116 // exactly the "geolocation was never configured" case the gate is for.
2117 // It is also what keeps this whole file safe to run: everything past the
2118 // gate would build a real `CLLocationManager` and raise a TCC prompt
2119 // against the test process.
2120 if (resolveAuthorization()) |_| {
2121 // A runner that *does* carry the key (tests hosted inside a real app)
2122 // would take the live path below, so skip rather than prompt.
2123 return error.SkipZigTest;
2124 } else |err| switch (err) {
2125 bridge_error.BridgeError.PermissionDenied => {
2126 var bridge = LocationBridge.init(testing.allocator);
2127 defer bridge.deinit();
2128
2129 try testing.expectError(
2130 bridge_error.BridgeError.PermissionDenied,
2131 bridge.handleMessage(A.get_current_position, "{}"),
2132 );
2133
2134 // `watchPosition` is wrapped in the same `config.enableGeolocation`
2135 // check in the Swift dispatcher, so it takes the same gate.
2136 try testing.expectError(
2137 bridge_error.BridgeError.PermissionDenied,
2138 bridge.handleMessage(A.watch_position, "{}"),
2139 );
2140
2141 // `clearWatch` is *not*: `case "clearWatch":` carries no capability
2142 // check at all, so a page can always stop a watch — including in an
2143 // app that could never have started one, where it is a no-op that
2144 // still replies `true`. Borrowing getCurrentPosition's gate here
2145 // would refuse a stop the shim always performs.
2146 try bridge.handleMessage(A.clear_watch, "{}");
2147
2148 // The refusal must happen before the manager is built and before a
2149 // slot is leased: an unreleased lease narrows the pool for every
2150 // later call, and a manager built here would outlive the test. The
2151 // no-op `clearWatch` must not have built one either.
2152 try testing.expect(manager == null);
2153 try testing.expect(takePendingFix() == null);
2154 try testing.expect(!takeWatchAndPending().watching);
2155 },
2156 else => return err,
2157 }
2158}
2159
2160test "the payload is ignored, not parsed" {
2161 if (!is_darwin) return error.SkipZigTest;
2162 // Three real dispatches, so this needs the process the gate stops.
2163 if (resolveAuthorization()) |_| return error.SkipZigTest else |_| {}
2164
2165 // The page drops `options` before posting — `legacyGeolocation.getCurrentPosition`
2166 // takes no parameters — and the Swift dispatcher reads nothing out of
2167 // `body`. A payload that is not even JSON must therefore reach exactly the
2168 // same outcome as `{}`; if it did not, this module would have invented a
2169 // failure the shim does not have.
2170 var bridge = LocationBridge.init(testing.allocator);
2171 defer bridge.deinit();
2172
2173 const empty = bridge.handleMessage(A.get_current_position, "{}");
2174 const junk = bridge.handleMessage(A.get_current_position, "{not json");
2175 const options = bridge.handleMessage(
2176 A.get_current_position,
2177 "{\"enableHighAccuracy\":true,\"timeout\":5000,\"maximumAge\":0}",
2178 );
2179
2180 try testing.expectEqual(empty, junk);
2181 try testing.expectEqual(empty, options);
2182 if (empty) |_| {} else |err| {
2183 try testing.expect(err != bridge_error.BridgeError.InvalidJSON);
2184 try testing.expect(err != bridge_error.BridgeError.MissingData);
2185 }
2186
2187 // `watchPosition` is posted with no payload by both JS surfaces, and
2188 // `clearWatch` posts no `id` — `nextLocationWatchId` is a JS-side map key.
2189 // So neither may acquire a way to fail on a payload it never receives.
2190 const watch_empty = bridge.handleMessage(A.watch_position, "{}");
2191 const watch_junk = bridge.handleMessage(A.watch_position, "{not json");
2192 try testing.expectEqual(watch_empty, watch_junk);
2193
2194 try bridge.handleMessage(A.clear_watch, "{}");
2195 try bridge.handleMessage(A.clear_watch, "{not json");
2196 try bridge.handleMessage(A.clear_watch, "{\"id\":3}");
2197 try testing.expect(!takeWatchAndPending().watching);
2198}
2199
2200test "every selector the delegate needs resolves on a real runtime" {
2201 if (!is_darwin) return error.SkipZigTest;
2202
2203 // `sel_registerName` interns a name whether or not any class implements it,
2204 // so this touches no framework and prompts for nothing. What it catches is
2205 // a typo in one of the nine spellings, which would otherwise surface as a
2206 // rejected position request on a device.
2207 const sels = try Sels.resolve();
2208 inline for (comptime std.meta.fieldNames(Sels)) |name| {
2209 if (@field(sels, name) == null) {
2210 std.debug.print("selector for Sels.{s} did not resolve\n", .{name});
2211 return error.SelectorDidNotResolve;
2212 }
2213 }
2214
2215 // Distinct selectors, or two readings would come from one property.
2216 try testing.expect(sels.horizontal_accuracy != sels.vertical_accuracy);
2217 try testing.expect(sels.course != sels.speed);
2218 try testing.expect(sels.altitude != sels.coordinate);
2219}
2220
2221test "the delegate class registers under its own name, idempotently" {
2222 if (!is_darwin) return error.SkipZigTest;
2223
2224 // No CoreLocation is involved: this allocates an NSObject subclass and one
2225 // instance. It is the load-bearing half of the delegate — a class that does
2226 // not register, or that adopts the desktop's IMPs, is a position request
2227 // that never answers on a device.
2228 const first = try ensureDelegate();
2229
2230 // `objc_getClass` first means a second call must hand back the same live
2231 // instance rather than registering a duplicate class pair.
2232 const second = try ensureDelegate();
2233 try testing.expectEqual(first, second);
2234
2235 // And it really is our class, not `bridge_location.zig`'s
2236 // `CraftLocationDelegate` — which is compiled into this same host binary,
2237 // looks itself up with `objc_getClass` too, and registers three methods
2238 // whose IMPs read different properties and fire desktop events.
2239 try testing.expect(!std.mem.eql(u8, delegate_class_name, "CraftLocationDelegate"));
2240 const cls = objc.object_getClass(first) orelse return error.DelegateHasNoClass;
2241 try testing.expectEqualStrings(delegate_class_name, std.mem.span(objc.class_getName(cls)));
2242
2243 // Both methods were added. CoreLocation dispatches through
2244 // `respondsToSelector:`, so a method that failed to attach is a callback
2245 // that is simply never made — silence, on an untimed promise.
2246 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse
2247 return error.SelectorNotFound;
2248 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
2249 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
2250
2251 for ([_][*:0]const u8{
2252 "locationManager:didUpdateLocations:",
2253 "locationManager:didFailWithError:",
2254 }) |name| {
2255 const sel = objc.sel_registerName(name) orelse return error.SelectorNotFound;
2256 if (!responds(first, sel_responds, sel)) {
2257 std.debug.print("the delegate does not respond to {s}\n", .{name});
2258 return error.DelegateMissingMethod;
2259 }
2260 }
2261
2262 // And it deliberately does not implement the authorization callback: there
2263 // is no iOS contract for one, so implementing it would mean inventing a
2264 // channel no page subscribes to.
2265 const sel_auth = objc.sel_registerName("locationManagerDidChangeAuthorization:") orelse
2266 return error.SelectorNotFound;
2267 try testing.expect(!responds(first, sel_responds, sel_auth));
2268}
packages/zig/src/bridge_mobile_locrecording.zigadded+1184-0
Changes to packages/zig/src/bridge_mobile_locrecording.zig
@@ -0,0 +1,1184 @@
1//! The one location-recording action of the `mobile` namespace that Zig can
2//! serve honestly: `readLocationRecording`.
3//!
4//! `startLocationRecording`, `pauseLocationRecording`, `resumeLocationRecording`,
5//! `stopLocationRecording` and `getLocationRecordingState` are deliberately
6//! **absent** from the `A` block — not `.unavailable`, absent — so
7//! `ios_dispatch`'s first-match chain falls through to the Swift shim that
8//! serves all five correctly today. The last two sections give the full
9//! argument; the short version is that the recorder is not a CLLocationManager
10//! plus a buffer, it is a CLLocationManager plus two files that
11//! `CraftWebView.Coordinator.init` re-adopts on every launch, and a Zig-owned
12//! `stop` cannot stop a recording Swift has already restored into its own
13//! process state.
14//!
15//! One JS surface reaches these six, `CraftApp.swift:2379-2384`:
16//!
17//! ```js
18//! startRecording: function() { return craft._invoke('startLocationRecording'); },
19//! pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
20//! resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
21//! stopRecording: function() { return craft._invoke('stopLocationRecording'); },
22//! getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
23//! readRecording: function() { return craft._invoke('readLocationRecording'); }
24//! ```
25//!
26//! Every one is called with **no second argument**, so `_invoke`'s
27//! `Object.assign({}, payload || {}, {action, callbackId})` posts the action
28//! alone and `ios_dispatch.payloadOf` hands this module `"{}"`. There is no
29//! payload field to carry, and none is invented. The SDK's
30//! `startRecording(options)` does accept `enableHighAccuracy`/`timeout`/
31//! `maximumAge`, but the injected `function()` takes no parameter, so those are
32//! discarded *in the page* before the bridge sees them; Swift never reads them
33//! either — accuracy is fixed once at `CraftApp.swift:444-446`. Honouring them
34//! here would be an invention, not a port.
35//!
36//! ## What `readLocationRecording` owes the page, exactly
37//!
38//! - **The reply is a bare JSON array**, never a wrapper object.
39//! `readLocationRecording` is `resolveCallback(callbackId, result:
40//! loadRecordedLocations())` (`CraftApp.swift:3026-3028`) and
41//! `resolveCallback` serialises with `[.fragmentsAllowed]`, which permits a
42//! bare value and never wraps a container. So the wire shape is
43//! `[{…},{…}]`, and `[]` when there is no track file. A `{"locations":[…]}`
44//! wrapper would be the `stopLocationRecording` shape, which is a different
45//! action this module does not serve.
46//! - **`sampleCount` is not part of this reply.** It appears on exactly one
47//! action, `getLocationRecordingState`. The SDK's *web fallback* adds it to
48//! start/pause/resume too (`mobile.ts:968,976,984`), but the iOS native path
49//! does not, and Swift is the contract being preserved.
50//! - **The samples keep the eight keys the writer wrote**: `latitude`,
51//! `longitude`, `altitude`, `accuracy`, `altitudeAccuracy`, `heading`,
52//! `speed`, `timestamp` (`CraftApp.swift:3072-3081`). This module never
53//! parses a sample into fields — it echoes the recorded line's bytes — so
54//! there is no field list here to drift out of step with the writer's, and
55//! no way for a key to be dropped or renamed on the way out.
56//! - **The file is `<AppSupport>/craft-location-recording.jsonl`**, directly
57//! in Application Support with no per-app subdirectory, which is where
58//! `locationRecordingTrackURL` (`CraftApp.swift:2908-2911`) puts it. Reading
59//! a different path would answer `[]` for a recording that exists.
60//!
61//! ## Echoing bytes rather than reparsing, and why that is the faithful route
62//!
63//! A sample is eight doubles. Parsing one to `f64` and re-rendering it with
64//! `{d}` needs `std.fmt.float.bufferSize(.decimal, f64)` = 347 bytes *per
65//! field* — 1e300 alone is 301 digits — and a too-small buffer would turn a
66//! legal sample into a `NoSpaceLeft` refusal. More importantly the round trip
67//! is lossy in a way nothing downstream can detect: a `heading` of
68//! `1.7976931348623157e308` that comes back rounded is a wrong answer reported
69//! as a right one.
70//!
71//! So the reader validates each line and then copies its **original bytes**
72//! into the array. There is no float formatting anywhere in this module, so
73//! the buffer-size hazard is structurally absent rather than merely handled —
74//! and the test suite pins that by round-tripping a 301-digit magnitude
75//! verbatim. This is also strictly closer to the recorded truth than Swift's
76//! own path, which goes through `JSONSerialization` in both directions.
77//!
78//! ## Where this deliberately differs from `loadRecordedLocations()`
79//!
80//! Swift, `CraftApp.swift:3062-3068`:
81//!
82//! ```swift
83//! guard let text = try? String(contentsOf: locationRecordingTrackURL, encoding: .utf8) else { return [] }
84//! return text.split(separator: "\n").compactMap { line in
85//! guard let data = line.data(using: .utf8) else { return nil }
86//! return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
87//! }
88//! ```
89//!
90//! Three behaviours are carried across unchanged, and three are not:
91//!
92//! - **Carried: a missing file is `[]`.** There is no recording, so there are
93//! no samples. That is a true answer, not a fabricated one.
94//! - **Carried: empty lines are dropped.** `split(separator:)` omits empty
95//! subsequences by default, and a trailing newline — which the appender
96//! always writes — is otherwise a phantom sample.
97//! - **Carried: a line that is not a JSON *object* is dropped, silently by
98//! Swift and with a counted log line here.** `as? [String: Any]` fails for
99//! an array, a number, a string or a torn half-line, and `compactMap` drops
100//! it. That drop is the *designed* recovery of an append-only NDJSON log: a
101//! crash mid-`write` leaves a partial tail line, and dropping it is how the
102//! format is meant to degrade.
103//! - **Not carried: one bad byte losing the whole file.**
104//! `String(contentsOf:encoding:)` returns nil for a file that is not valid
105//! UTF-8 anywhere, so a single torn multi-byte sequence at the tail makes
106//! Swift answer `[]` for a track full of real fixes. This module validates
107//! UTF-8 **per line**, so the torn line is dropped and the samples before it
108//! survive. Every element it emits is still a genuinely recorded sample —
109//! the divergence returns more of the page's own data and cannot invent any.
110//! - **Not carried: a file that exists and cannot be read answering `[]`.**
111//! Swift's `try?` flattens "there is no recording" and "the recording is
112//! there and I could not open it" into the same empty array. The second is a
113//! real possibility: the track file carries
114//! `NSFileProtectionCompleteUntilFirstUserAuthentication`, so a
115//! background launch before first unlock cannot open it. Answering `[]`
116//! there tells a page its route is empty when it is intact on disk — the
117//! fabricated-success shape, on the one action whose entire job is not to
118//! lose a route. An open or read failure is an error here, with the cause
119//! logged. Only `FileNotFound` is `[]`.
120//! - **Not carried: an unbounded read.** Swift materialises a track of any
121//! size; this module refuses one over `max_track_bytes` (32 MiB, roughly
122//! 176,000 fixes) rather than truncate it, for the reason written out at
123//! that constant. This is the one divergence that answers *less* than
124//! Swift, and it is listed here rather than only at the constant because a
125//! section enumerating the differences has to enumerate all of them. It is
126//! reachable only past about 49 hours of continuous one-per-second
127//! recording, and the alternative — a track silently cut off at 32 MiB and
128//! reported as the whole one — is the failure this module exists to avoid.
129//!
130//! ## Why the other five are absent rather than `.unavailable`
131//!
132//! `CraftWebView.makeCoordinator()` builds the `Coordinator` specifically so
133//! the shim has a host (`CraftSwiftShim.coordinator = coordinator`,
134//! `CraftApp.swift:371`), and `Coordinator.init` calls
135//! `restoreLocationRecordingState()` at line 447 whenever
136//! `config.enableGeolocation`. **That happens on every launch no matter who
137//! serves these actions.** So:
138//!
139//! - A Zig `start` must persist `{"active":true,…}` or the recording dies at
140//! app termination while Swift's survives. Having persisted it, the next
141//! launch has Swift restore it: its own `isRecordingLocation = true`, its
142//! own manager, its own `startUpdatingLocation()`, appending to the same
143//! `.jsonl`.
144//! - A later Zig `stop` can flip the file to `active:false` and stop *Zig's*
145//! manager. It cannot clear Swift's in-memory `isRecordingLocation` or stop
146//! Swift's manager: `CraftSwiftShim.coordinator` is a Swift `static weak
147//! var` with no `@objc` surface, and `ios_dispatch.route` only reaches the
148//! shim for actions **no** module claims. The page's promise resolves
149//! `active:false` while GPS keeps running and keeps writing fixes to disk —
150//! fabricated success on a location-privacy operation, invisible from Zig.
151//! - Refusing to persist `active:true` avoids the hijack and silently loses
152//! the recording on termination instead, which is the exact failure the
153//! feature exists to prevent.
154//!
155//! `getLocationRecordingState` has a second, independent blocker: Swift answers
156//! it from **memory**, and `restoreLocationRecordingState`'s
157//! `guard state["active"] as? Bool == true else { return }` leaves
158//! `locationRecordingId`/`startedAt` nil after any relaunch that follows a
159//! stop, while the state file still holds the stopped recording's `id` and
160//! `startedAt`. A file-reading Zig would answer `{"id":"ABC-…","startedAt":169…}`
161//! where Swift answers `{"id":null,"startedAt":null}` — a guaranteed,
162//! reproducible divergence, made worse by `persistLocationRecordingState`
163//! swallowing its own write failures so memory and file can drift with nothing
164//! to signal it.
165//!
166//! And `startLocationRecording` is the only one of the six behind
167//! `config.enableGeolocation` (`CraftApp.swift:635-640`), a flag with no mirror
168//! anywhere in `packages/zig/src` — `ios.zig`'s `AppConfig` has no such field,
169//! the precedent and its consequences being written out at
170//! `bridge_mobile_system.zig:39-43` for `enableShare`. Serving it ungated means
171//! turning on GPS and writing a location track to disk in an app that
172//! explicitly disabled geolocation, which is a materially larger divergence
173//! than an unexpected share sheet.
174//!
175//! Per the migration's standing rule, falling through beats `.unavailable`:
176//! `.unavailable` would make Zig dispatch and *refuse* five actions that work
177//! end-to-end today, including the capability gate, the authorization mode,
178//! background continuation, atomic persistence, file protection, backup
179//! exclusion and relaunch restore. Absence keeps them working.
180//!
181//! Migrating them honestly means deleting the recorder from `CraftApp.swift` in
182//! the same change — the six `case` arms (635-650), the six handlers
183//! (2960-3028), the file helpers (2904-2911, 2927-2958, 3039-3068), the four
184//! state vars (392-395) and the `restoreLocationRecordingState()` call at 447 —
185//! and untangling `isRecordingLocation` from `stopWatchingPosition` and from
186//! `didUpdateLocations`'s emit guard. It also needs an event channel that does
187//! not exist: the recorder's live samples go out as **`craftLocationUpdate`**
188//! (`CraftApp.swift:3090`, listeners at 1648 and 2362), and
189//! `capabilities.Channel` has no member for that name —
190//! `.location_update` is `"craft:location:update"`, which no iOS page listens
191//! for. Any partial split of this state across the two languages reintroduces
192//! the hijack above, so it is not an incremental move.
193//!
194//! ## No delegate, no manager, no event, no async
195//!
196//! This module reads one file and replies. It registers no Objective-C class,
197//! keeps no delegate alive, touches no `CLLocationManager`, emits on no
198//! channel, and takes no `ios_async` ticket: `loadRecordedLocations()` is
199//! synchronous and so is this. `ios_dispatch.handleMessage` runs from
200//! `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback WebKit
201//! delivers on the main thread, so the reply is sent from the main thread with
202//! no hop — the reasoning is written out at `bridge_mobile_display.zig`.
203//!
204//! No mutex either, and that is not an oversight. The premise of a
205//! mutex-guarded in-memory buffer does not match this design: the writer is
206//! `appendRecordedLocation`, called from `locationManager(_:didUpdateLocations:)`,
207//! which CoreLocation delivers on the thread that created the manager — the
208//! main thread — and the reader is this dispatch, also on the main thread.
209//! The shared state is a file, and the two never run concurrently.
210
211const std = ;
212const builtin = ;
213const capabilities = ;
214const bridge_error = ;
215const objc_runtime = ;
216const io_context = ;
217
218const objc = objc_runtime.objc;
219const is_darwin = builtin.target.os.tag.isDarwin();
220
221/// The same type as `objc.id` — `?*anyopaque` — spelled locally.
222///
223/// `objc_runtime.objc` is an empty struct off Darwin, and a function
224/// *signature* is analysed even when a comptime platform guard makes its body
225/// unreachable, so naming `objc.id` in the `callconv(.c)` type below would
226/// break the host build. It stays a single optional pointer, never `?objc.id`:
227/// a double optional is illegal in a `callconv(.c)` type.
228const Id = ?*anyopaque;
229
230/// The action names, spelled exactly as the Swift `case` labels spell them.
231/// `test/ios_conformance_test.zig` matches the two lists by string in both
232/// directions.
233///
234/// The other five recording actions are absent on purpose — the module
235/// comment's second-to-last section gives the full argument. An action listed
236/// here is an action this module takes away from the Swift shim, and that trade
237/// is only worth making when Zig's answer is at least as good.
238pub const A = struct {
239 pub const read_location_recording = "readLocationRecording";
240};
241
242/// `.result`: the Swift path terminates in exactly one `resolveCallback`, and
243/// `craft.location.readRecording()` returns a promise the page awaits. `.none`
244/// would strand that caller.
245///
246/// `.live`, not `.unavailable`: reading a file is synchronous, needs no
247/// permission prompt and no entitlement. `.unavailable` is for an action that
248/// dispatches and refuses, and this one does neither.
249pub const capability_actions = [_]capabilities.ActionDecl{
250 .{ .name = A.read_location_recording, .reply = .result },
251};
252
253/// Which handler an action selects, split out from `handleMessage` so the
254/// table-versus-dispatch agreement is assertable on a host.
255const Route = enum { read_recording };
256
257fn routeFor(action: []const u8) ?Route {
258 if (std.mem.eql(u8, action, A.read_location_recording)) return .read_recording;
259 return null;
260}
261
262/// The track file the recorder appends to, relative to Application Support.
263///
264/// `CraftApp.swift:2908-2911` puts it directly in Application Support with no
265/// per-app subdirectory. The sibling `craft-location-recording-state.json` is
266/// deliberately not read anywhere in this module: it is the summary's source,
267/// and the summary is a shape this module does not serve.
268const track_file_name = "craft-location-recording.jsonl";
269
270/// The largest track this module will read into memory.
271///
272/// Matching `cli.zig`'s `max_html_file_bytes`. At the recorder's ~190 bytes per
273/// sample this is roughly 176,000 fixes — about 49 hours at one per second.
274/// Beyond it the read is **refused, never truncated**: a truncated track
275/// reported as the whole one is a wrong answer delivered as a right one, and
276/// the page has no way to tell.
277const max_track_bytes: u64 = 32 * 1024 * 1024;
278
279/// The reply for "there is no recording to read".
280const empty_array = "[]";
281
282pub const LocationRecordingBridge = struct {
283 allocator: std.mem.Allocator,
284
285 const Self = @This();
286
287 pub fn init(allocator: std.mem.Allocator) Self {
288 return .{ .allocator = allocator };
289 }
290
291 pub fn deinit(_: *Self) void {}
292
293 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
294 // The payload is ignored, because there is nothing in it. The injected
295 // JS posts the action alone and the Swift dispatcher reads nothing out
296 // of `body`, so parsing `d` here would turn a page that sends a stray
297 // field — or nothing at all — into an `INVALID_JSON` rejection for a
298 // call Swift answers. Ignored, not parsed: the same call
299 // `bridge_mobile_notifications.zig` makes for `getPendingNotifications`.
300 _ = data;
301
302 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
303 // Exhaustive, so a `Route` without a handler is a compile error.
304 return switch (route) {
305 .read_recording => self.readLocationRecording(),
306 };
307 }
308
309 /// Reply with every sample the recorder has written, as a bare JSON array.
310 ///
311 /// `loadRecordedLocations()` in Zig: read the track file, split on newlines,
312 /// keep the lines that are JSON objects, emit their bytes. A missing file is
313 /// `[]`; an unreadable one is an error (see the module comment).
314 fn readLocationRecording(self: *Self) !void {
315 const path = try trackFilePath(self.allocator);
316 defer self.allocator.free(path);
317
318 const text = try readTrackBytes(self.allocator, path) orelse {
319 // No track file: no recording has ever been started, or the app
320 // container was cleared. Swift answers `[]` and so does this —
321 // there genuinely are no samples, so it is not a fabricated answer.
322 bridge_error.sendResultToJS(self.allocator, A.read_location_recording, empty_array);
323 return;
324 };
325 defer self.allocator.free(text);
326
327 const json = try shapeRecording(self.allocator, text);
328 defer self.allocator.free(json);
329
330 bridge_error.sendResultToJS(self.allocator, A.read_location_recording, json);
331 }
332};
333
334// =============================================================================
335// Shaping. Pure, so every line-level decision Swift made silently is pinnable
336// on a host — on any platform, with no CoreLocation and no app container.
337// =============================================================================
338
339/// The recorded track as the bare JSON array `readLocationRecording` resolves.
340///
341/// Each surviving line's **original bytes** go into the array; nothing is
342/// parsed into fields and re-rendered. A line is kept when it is valid UTF-8
343/// and parses as a single JSON *object* — the two conditions
344/// `line.data(using: .utf8)` and `as? [String: Any]` impose in Swift, applied
345/// per line rather than per file.
346///
347/// Allocation failure propagates rather than being read as a corrupt line: an
348/// OOM that silently dropped a sample would report a short track as the whole
349/// one.
350fn shapeRecording(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
351 var out: std.ArrayListUnmanaged(u8) = .empty;
352 errdefer out.deinit(allocator);
353
354 try out.append(allocator, '[');
355
356 var kept: usize = 0;
357 var dropped: usize = 0;
358
359 var it = std.mem.splitScalar(u8, text, '\n');
360 while (it.next()) |line| {
361 // `split(separator:)` omits empty subsequences, and the appender always
362 // writes a trailing newline — without this every file would end in a
363 // phantom sample.
364 if (line.len == 0) continue;
365
366 if (!try lineIsJsonObject(allocator, line)) {
367 dropped += 1;
368 continue;
369 }
370
371 if (kept > 0) try out.append(allocator, ',');
372 try out.appendSlice(allocator, line);
373 kept += 1;
374 }
375
376 try out.append(allocator, ']');
377
378 if (dropped > 0) {
379 // Swift drops these without a word. The drop itself is the format's
380 // designed recovery for a torn tail, but a page reporting missing
381 // samples deserves something to point at.
382 std.log.warn(
383 "readLocationRecording: {d} of {d} recorded lines were not JSON objects and were dropped",
384 .{ dropped, dropped + kept },
385 );
386 }
387
388 return out.toOwnedSlice(allocator);
389}
390
391/// Whether one line is a single JSON object, and therefore safe to copy into
392/// the array verbatim.
393///
394/// UTF-8 is checked first: `std.json` would reject most invalid sequences
395/// anyway, but the emitted bytes land in a JavaScript source position, and
396/// "valid JSON" and "valid UTF-8" are worth establishing separately rather than
397/// inferring one from the other.
398///
399/// `parseFromSlice` is whole-document, so a line holding two objects
400/// (`{"a":1} {"b":2}` — what a torn concurrent write can leave) is refused
401/// rather than copied, which would have produced an array that is not JSON at
402/// all.
403///
404/// `duplicate_field_behavior` is `.use_last` rather than `std.json`'s default
405/// of `.@"error"`. This check stands in for `as? [String: Any]`, and the
406/// `JSONSerialization` behind it accepts a repeated key and keeps the last —
407/// as does the `JSON.parse` that reads these bytes back out at the far end.
408/// Leaving the default would drop a line both of the other two parsers accept,
409/// which is a recorded sample lost to a Zig-only strictness the page never
410/// asked for. `appendRecordedLocation` cannot write one (it serialises a Swift
411/// dictionary, whose keys are unique by construction), so this is about not
412/// losing a line some other native writer appended.
413fn lineIsJsonObject(allocator: std.mem.Allocator, line: []const u8) !bool {
414 if (!std.unicode.utf8ValidateSlice(line)) return false;
415
416 const options: std.json.ParseOptions = .{ .duplicate_field_behavior = .use_last };
417 var parsed = std.json.parseFromSlice(std.json.Value, allocator, line, options) catch |err| switch (err) {
418 error.OutOfMemory => return err,
419 else => return false,
420 };
421 defer parsed.deinit();
422
423 return parsed.value == .object;
424}
425
426// =============================================================================
427// The Objective-C and filesystem half.
428//
429// The Objective-C path is Darwin-gated: off Darwin there is no app container to
430// look in, and saying so beats answering `[]` for a file nothing looked for.
431// `readTrackBytes` is deliberately *not* gated — it takes a path rather than
432// finding one, so the host tests drive every one of its outcomes against a real
433// file on any platform.
434// =============================================================================
435
436/// `NSApplicationSupportDirectory`.
437const ns_application_support_directory: c_ulong = 14;
438/// `NSUserDomainMask`.
439const ns_user_domain_mask: c_ulong = 1;
440/// `NSUTF8StringEncoding`.
441const ns_utf8_string_encoding: c_ulong = 4;
442
443/// The absolute path of `<AppSupport>/craft-location-recording.jsonl`.
444///
445/// `NSFileManager` → `URLsForDirectory:inDomains:` → `firstObject` → `path`,
446/// which is the chain `locationRecordingTrackURL` runs, with every step guarded
447/// — a nil anywhere is a named error, never a message to nil read as a path.
448///
449/// Swift subscripts the array with `[0]`, which raises `NSRangeException` on an
450/// empty result; `firstObject` answers nil instead, and nil here is a refusal
451/// rather than an uncatchable SIGABRT. In every case where the two differ,
452/// Swift crashed.
453fn trackFilePath(allocator: std.mem.Allocator) ![]u8 {
454 const directory = try applicationSupportPath(allocator);
455 defer allocator.free(directory);
456
457 return std.fmt.allocPrint(allocator, "{s}/" ++ track_file_name, .{directory});
458}
459
460fn applicationSupportPath(allocator: std.mem.Allocator) ![]u8 {
461 if (!is_darwin) return error.UnsupportedPlatform;
462
463 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound;
464 const sel_default = objc.sel_registerName("defaultManager") orelse return error.SelectorNotFound;
465 const manager = objc.msgSendId(NSFileManager, sel_default) orelse return error.NativeCallFailed;
466
467 const sel_urls = objc.sel_registerName("URLsForDirectory:inDomains:") orelse return error.SelectorNotFound;
468 // NSUInteger arguments: explicit `c_ulong`, because the variadic msgSend
469 // cast takes its argument types from what is passed.
470 const urls = objc.msgSendId2(
471 manager,
472 sel_urls,
473 ns_application_support_directory,
474 ns_user_domain_mask,
475 ) orelse return error.NativeCallFailed;
476
477 const sel_first = objc.sel_registerName("firstObject") orelse return error.SelectorNotFound;
478 const url = objc.msgSendId(urls, sel_first) orelse return error.NotFound;
479
480 const sel_path = objc.sel_registerName("path") orelse return error.SelectorNotFound;
481 const path_obj = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed;
482
483 return copyNSString(allocator, path_obj);
484}
485
486/// An `NSString` as owned bytes, refusing a value `UTF8String` truncated.
487///
488/// `-[NSString UTF8String]` is NUL-terminated, so a path carrying an embedded
489/// U+0000 would read back short — and a short path names a *different* file,
490/// whose absence would then be reported as "this recording has no samples".
491/// That is the fabricated-success shape, so the C-string length is checked
492/// against `lengthOfBytesUsingEncoding:` and a mismatch fails the read.
493/// (`bridge_mobile_notifications.zig` makes the same check for the same
494/// reason.)
495fn copyNSString(allocator: std.mem.Allocator, ns_string: Id) ![]u8 {
496 if (!is_darwin) return error.UnsupportedPlatform;
497
498 const cstr = objc.getNSStringUTF8(ns_string) orelse return error.NativeCallFailed;
499 const bytes = std.mem.span(cstr);
500
501 const sel_len = objc.sel_registerName("lengthOfBytesUsingEncoding:") orelse return error.SelectorNotFound;
502 const LenFn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) c_ulong;
503 const len_fn: LenFn = @ptrCast(&objc.objc_msgSend);
504 const declared = len_fn(ns_string, sel_len, ns_utf8_string_encoding);
505
506 if (declared != bytes.len) {
507 std.log.warn(
508 "readLocationRecording: Application Support path is {d} UTF-8 bytes but reads back as {d}; refusing rather than reading some other file",
509 .{ declared, bytes.len },
510 );
511 return error.NativeCallFailed;
512 }
513
514 return allocator.dupe(u8, bytes);
515}
516
517/// The track file's bytes, or null when there is no track file.
518///
519/// Null is the only case that becomes `[]`. Everything else — a permission
520/// failure under `NSFileProtectionCompleteUntilFirstUserAuthentication`, a
521/// directory where the file should be, a short read — is an error with the
522/// cause logged, because "I could not read it" and "it is empty" are different
523/// answers and only one of them is true.
524///
525/// Takes the path as an argument so the host tests can drive it against a real
526/// file on any platform, rather than only against the app container that exists
527/// on exactly one.
528fn readTrackBytes(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
529 const io = io_context.get();
530
531 const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
532 error.FileNotFound => return null,
533 else => {
534 std.log.warn("readLocationRecording: could not open '{s}': {}", .{ path, err });
535 return bridge_error.BridgeError.NativeCallFailed;
536 },
537 };
538 defer file.close(io);
539
540 const info = file.stat(io) catch |err| {
541 std.log.warn("readLocationRecording: could not stat '{s}': {}", .{ path, err });
542 return bridge_error.BridgeError.NativeCallFailed;
543 };
544
545 if (info.size > max_track_bytes) {
546 std.log.warn(
547 "readLocationRecording: track file is {d} bytes, over the {d}-byte ceiling; refusing rather than reporting a truncated route as the whole one",
548 .{ info.size, max_track_bytes },
549 );
550 return bridge_error.BridgeError.NativeCallFailed;
551 }
552
553 var buf = try allocator.alloc(u8, @intCast(info.size));
554 errdefer allocator.free(buf);
555
556 var read: usize = 0;
557 while (read < buf.len) {
558 const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) {
559 error.EndOfStream => break,
560 else => {
561 std.log.warn("readLocationRecording: could not read '{s}': {}", .{ path, err });
562 return bridge_error.BridgeError.NativeCallFailed;
563 },
564 };
565 if (n == 0) break;
566 read += n;
567 }
568
569 if (read < buf.len) {
570 // Not `startLocationRecording`'s truncation: that is
571 // `Data().write(to:options:.atomic)`, which writes an auxiliary file
572 // and renames it over the directory entry, so a descriptor already
573 // open on the old inode never sees the file shrink. The resize is here
574 // for the reason that holds whatever caused the short read — the tail
575 // of this allocation was never written, and handing back undefined
576 // bytes shaped like recorded samples is worse than any of the causes.
577 //
578 // Said out loud rather than absorbed: a short read means the reply is
579 // missing samples the stat counted, and a truncated track reported as
580 // a whole one is what `max_track_bytes` above refuses outright.
581 std.log.warn(
582 "readLocationRecording: '{s}' stat'd {d} bytes but only {d} could be read; the reply covers what was read",
583 .{ path, buf.len, read },
584 );
585 buf = try allocator.realloc(buf, read);
586 }
587
588 return buf;
589}
590
591// =============================================================================
592// Tests.
593//
594// Everything that decides what the page sees is pure and pinned here: routing
595// in both directions, the five deliberate omissions, the ignored payload, and
596// the whole of the shaping — bare array, verbatim bytes, extreme magnitudes,
597// the line-level drops, and the round trip back through a JSON parser.
598//
599// The filesystem tier runs on every platform against a real file this test
600// writes, so the missing-file, empty-file, torn-tail and shrink cases are
601// exercised for real rather than described. The one Darwin-only tier resolves
602// the app container path through live Objective-C. Nothing anywhere writes to
603// the recorder's own files: this module is a reader, and a test that created
604// `~/Library/Application Support/craft-location-recording.jsonl` on a
605// developer's machine would be inventing a recording.
606// =============================================================================
607
608const testing = std.testing;
609
610test "the declared actions are the ones the handler serves" {
611 try testing.expectEqual(@as(usize, 1), capability_actions.len);
612 try testing.expectEqualStrings(A.read_location_recording, capability_actions[0].name);
613
614 for (capability_actions) |decl| {
615 try testing.expectEqual(capabilities.Reply.result, decl.reply);
616 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
617 // `.reason` is only meaningful on an `.unavailable` row; a stray one
618 // here would be shown to an app about an action that works.
619 try testing.expect(decl.reason == null);
620 }
621}
622
623test "the action name matches the Swift case label exactly" {
624 // The conformance ratchet compares this against the `case "…":` labels in
625 // `CraftApp.swift` in both directions; a prettier spelling would read as
626 // Zig serving an action the spec does not have.
627 try testing.expectEqualStrings("readLocationRecording", A.read_location_recording);
628}
629
630test "every declared action is one the dispatcher routes" {
631 for (capability_actions) |decl| {
632 if (routeFor(decl.name) == null) {
633 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
634 return error.DeclaredActionDoesNotRoute;
635 }
636 }
637}
638
639test "every route the dispatcher has is a declared action" {
640 // The other direction: a route with a handler and no declaration is an
641 // action the page can call and the manifest denies exists. Each declaration
642 // must claim a *distinct* route — counting alone would let two rows share
643 // one route while another went undeclared.
644 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
645 for (capability_actions) |decl| {
646 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
647 const slot = @backingInt(route);
648 if (claimed[slot]) {
649 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
650 return error.TwoDeclarationsShareARoute;
651 }
652 claimed[slot] = true;
653 }
654 for (claimed, 0..) |taken, slot| {
655 if (!taken) {
656 std.debug.print(
657 "route {s} has a handler but no capability_actions row\n",
658 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
659 );
660 return error.RouteNotDeclared;
661 }
662 }
663}
664
665test "the five stateful recording actions are left to the Swift shim" {
666 // The deliberate omissions, asserted so they cannot be "fixed" by accident.
667 //
668 // `ios_dispatch`'s chain treats UnknownAction as "not mine, ask the next"
669 // and any other error as final. So the *only* spelling of "let the shim
670 // keep serving this" is: absent from `A`, absent from `routeFor`, and
671 // UnknownAction out of `handleMessage`. An `.unavailable` declaration would
672 // dispatch and refuse five actions the shim answers correctly today —
673 // including the `enableGeolocation` gate, the authorization mode,
674 // background continuation, atomic persistence, file protection, backup
675 // exclusion and relaunch restore, none of which Zig can reproduce while
676 // `Coordinator.init` still re-adopts the recording on every launch.
677 const unserved = [_][]const u8{
678 "startLocationRecording",
679 "pauseLocationRecording",
680 "resumeLocationRecording",
681 "stopLocationRecording",
682 "getLocationRecordingState",
683 };
684
685 var bridge = LocationRecordingBridge.init(testing.allocator);
686 defer bridge.deinit();
687
688 for (unserved) |action| {
689 try testing.expect(routeFor(action) == null);
690 try testing.expectError(
691 bridge_error.BridgeError.UnknownAction,
692 bridge.handleMessage(action, "{}"),
693 );
694 for (capability_actions) |decl| {
695 try testing.expect(!std.mem.eql(u8, decl.name, action));
696 }
697 }
698}
699
700test "an action the namespace does not serve is reported, not ignored" {
701 var bridge = LocationRecordingBridge.init(testing.allocator);
702 defer bridge.deinit();
703
704 try testing.expectError(
705 bridge_error.BridgeError.UnknownAction,
706 bridge.handleMessage("noSuchAction", "{}"),
707 );
708 // Near misses — casing is how a real typo arrives.
709 try testing.expectError(
710 bridge_error.BridgeError.UnknownAction,
711 bridge.handleMessage("readlocationrecording", "{}"),
712 );
713 try testing.expectError(
714 bridge_error.BridgeError.UnknownAction,
715 bridge.handleMessage("ReadLocationRecording", "{}"),
716 );
717 // The JS surface method names, which are not action names.
718 try testing.expectError(
719 bridge_error.BridgeError.UnknownAction,
720 bridge.handleMessage("readRecording", "{}"),
721 );
722 try testing.expectError(
723 bridge_error.BridgeError.UnknownAction,
724 bridge.handleMessage("getRecordingState", "{}"),
725 );
726 // The Swift *helper* name, which is not a dispatcher case.
727 try testing.expectError(
728 bridge_error.BridgeError.UnknownAction,
729 bridge.handleMessage("loadRecordedLocations", "{}"),
730 );
731 // The sibling namespace's actions, which other modules serve.
732 try testing.expectError(
733 bridge_error.BridgeError.UnknownAction,
734 bridge.handleMessage("getCurrentPosition", "{}"),
735 );
736}
737
738/// Run the served action and report only whether it failed, and how.
739///
740/// The payload tests below compare *outcomes* rather than pinning one, because
741/// the outcome legitimately differs by platform: off Darwin there is no app
742/// container to look in, and on a Darwin host the answer depends on whether the
743/// developer happens to have a track file. What must not differ is the effect
744/// of the payload, which is none.
745fn outcomeFor(data: []const u8) ?anyerror {
746 var bridge = LocationRecordingBridge.init(testing.allocator);
747 defer bridge.deinit();
748
749 bridge.handleMessage(A.read_location_recording, data) catch |err| return err;
750 return null;
751}
752
753test "the payload is ignored, exactly as Swift ignores it" {
754 // The injected JS posts `{action:'readLocationRecording', callbackId: id}`
755 // and nothing else, and the Swift dispatcher reads nothing out of `body`.
756 // A handler that parsed `d` would turn the malformed case into an
757 // `INVALID_JSON` rejection for a call Swift answers — which is why these
758 // are asserted *equal* rather than each asserted successful. A parsing
759 // handler fails this test on every platform.
760 const baseline = outcomeFor("{}");
761
762 try testing.expectEqual(baseline, outcomeFor("{\"callbackId\":\"cb_7\"}"));
763 try testing.expectEqual(baseline, outcomeFor("{not json"));
764 try testing.expectEqual(baseline, outcomeFor("[]"));
765 try testing.expectEqual(baseline, outcomeFor("null"));
766 try testing.expectEqual(baseline, outcomeFor("{\"enableHighAccuracy\":true,\"timeout\":5000}"));
767}
768
769test "off Darwin the handler refuses rather than answering an empty recording" {
770 if (is_darwin) return error.SkipZigTest;
771
772 // Notably *not* `[]`: off Darwin there is no app container, so nothing
773 // looked for a track file and "there are no samples" would be a claim
774 // nothing checked.
775 var bridge = LocationRecordingBridge.init(testing.allocator);
776 defer bridge.deinit();
777
778 try testing.expectError(
779 error.UnsupportedPlatform,
780 bridge.handleMessage(A.read_location_recording, "{}"),
781 );
782 try testing.expectError(error.UnsupportedPlatform, applicationSupportPath(testing.allocator));
783}
784
785// -----------------------------------------------------------------------------
786// Shaping.
787// -----------------------------------------------------------------------------
788
789fn expectShape(text: []const u8, expected: []const u8) !void {
790 const json = try shapeRecording(testing.allocator, text);
791 defer testing.allocator.free(json);
792 try testing.expectEqualStrings(expected, json);
793}
794
795/// One real sample, with the eight keys and the value magnitudes the recorder
796/// actually writes: `CraftApp.swift:3072-3081`, a fix in Cupertino.
797const real_sample =
798 "{\"latitude\":37.33182,\"longitude\":-122.03118,\"altitude\":12.5," ++
799 "\"accuracy\":5,\"altitudeAccuracy\":3,\"heading\":-1,\"speed\":-1," ++
800 "\"timestamp\":1700000000000}";
801
802test "an empty or absent recording is the bare array, not null and not a wrapper" {
803 // `loadRecordedLocations()` returns `[]` for a missing file, and
804 // `startLocationRecording` truncates the track with `Data().write`, so a
805 // zero-byte file is the normal state of a recording that has not yet taken
806 // a fix.
807 try expectShape("", empty_array);
808 try expectShape("\n", empty_array);
809 try expectShape("\n\n\n", empty_array);
810 try testing.expectEqualStrings("[]", empty_array);
811}
812
813test "the reply is a bare array of the recorded samples, not a wrapper object" {
814 // The rule-4 regression test. `resolveCallback` serialises with
815 // `.fragmentsAllowed`, which permits a bare value and never wraps a
816 // container — so `readLocationRecording` resolves `[{…}]`. A
817 // `{"locations":[…]}` shape is `stopLocationRecording`'s, a different
818 // action this module does not serve, and `{"samples":…}` is nobody's.
819 const json = try shapeRecording(testing.allocator, real_sample ++ "\n");
820 defer testing.allocator.free(json);
821
822 try testing.expectEqualStrings("[" ++ real_sample ++ "]", json);
823 try testing.expectEqual(@as(u8, '['), json[0]);
824 try testing.expectEqual(@as(u8, ']'), json[json.len - 1]);
825 try testing.expect(std.mem.indexOf(u8, json, "locations") == null);
826 try testing.expect(std.mem.indexOf(u8, json, "samples") == null);
827 // `sampleCount` belongs to `getLocationRecordingState` alone. The SDK's web
828 // fallback adds it to more replies than iOS does; Swift is the contract.
829 try testing.expect(std.mem.indexOf(u8, json, "sampleCount") == null);
830}
831
832test "every recorded key survives, spelled as the writer spelled it" {
833 // `heading` is `-course` and `accuracy` is `-horizontalAccuracy` while
834 // `altitudeAccuracy` is `-verticalAccuracy`; conflating any two would be a
835 // silently wrong reading rather than a missing one. This module never names
836 // the keys, which is exactly why: it copies the writer's bytes.
837 const json = try shapeRecording(testing.allocator, real_sample ++ "\n");
838 defer testing.allocator.free(json);
839
840 for ([_][]const u8{
841 "\"latitude\"", "\"longitude\"", "\"altitude\"", "\"accuracy\"",
842 "\"altitudeAccuracy\"", "\"heading\"", "\"speed\"", "\"timestamp\"",
843 }) |key| {
844 if (std.mem.indexOf(u8, json, key) == null) {
845 std.debug.print("shaped reply lost the key {s}\n", .{key});
846 return error.RecordedKeyDropped;
847 }
848 }
849
850 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
851 defer parsed.deinit();
852 try testing.expectEqual(@as(usize, 8), parsed.value.array.items[0].object.count());
853}
854
855test "several samples come back in file order, comma separated" {
856 try expectShape(
857 "{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n",
858 "[{\"a\":1},{\"a\":2},{\"a\":3}]",
859 );
860 // A final line with no trailing newline is still a sample: Swift's `split`
861 // yields it, and dropping it would lose the most recent fix — the one a
862 // page reading a live recording cares about most.
863 try expectShape("{\"a\":1}\n{\"a\":2}", "[{\"a\":1},{\"a\":2}]");
864}
865
866test "blank lines are dropped rather than becoming phantom samples" {
867 try expectShape("{\"a\":1}\n\n\n{\"a\":2}\n", "[{\"a\":1},{\"a\":2}]");
868}
869
870test "a line that is not a JSON object is dropped, as compactMap drops it" {
871 // `as? [String: Any]` fails for each of these, and a torn tail line is the
872 // reason the drop exists at all. Copying one through would produce an array
873 // whose elements are not samples — or, for the unterminated case, bytes
874 // that are not JSON.
875 try expectShape("[1,2]\n", empty_array);
876 try expectShape("\"a string\"\n", empty_array);
877 try expectShape("42\n", empty_array);
878 try expectShape("true\n", empty_array);
879 try expectShape("null\n", empty_array);
880 try expectShape("{\"latitude\":37.3\n", empty_array);
881 try expectShape("{broken\n", empty_array);
882 // And the neighbours survive: a torn tail must not cost the whole track,
883 // which is the one behaviour of `loadRecordedLocations()` deliberately not
884 // reproduced.
885 try expectShape(
886 "{\"a\":1}\n{\"a\":2}\n{\"latitude\":37.3",
887 "[{\"a\":1},{\"a\":2}]",
888 );
889}
890
891test "two objects on one line are refused, not concatenated into invalid JSON" {
892 // What a torn concurrent write can leave. Copying the line verbatim would
893 // emit `[{"a":1} {"b":2}]`, which is not JSON at all — the page's
894 // `_resolveCallback` would take a syntax error instead of a result. This is
895 // the load-bearing reason the check is a whole-document parse.
896 try expectShape("{\"a\":1} {\"b\":2}\n", empty_array);
897 try expectShape("{\"a\":1}{\"b\":2}\n", empty_array);
898 try expectShape("{\"a\":1},\n", empty_array);
899}
900
901test "a repeated key keeps the line, because Foundation and JSON.parse both keep it" {
902 // `std.json`'s default is `error.DuplicateField`, which would drop a line
903 // `JSONSerialization` accepts — a recorded sample lost to a parser
904 // difference rather than to anything wrong with the sample. The bytes go
905 // out untouched and the page's `JSON.parse` resolves it last-wins, which
906 // is the value Swift would have handed over.
907 try expectShape(
908 "{\"latitude\":1,\"latitude\":2}\n",
909 "[{\"latitude\":1,\"latitude\":2}]",
910 );
911 // And it still has to be an object: a repeated key does not rescue a line
912 // that is not one.
913 try expectShape("[1,1]\n", empty_array);
914}
915
916test "a line that is not valid UTF-8 is dropped without costing its neighbours" {
917 // Swift's `String(contentsOf:encoding:)` returns nil for the whole file
918 // here, so a single torn multi-byte sequence loses a track of real fixes.
919 // Per line, the torn line alone is lost.
920 const torn = "{\"a\":1}\n{\"b\":\"\xff\xfe\"}\n{\"a\":2}\n";
921 try expectShape(torn, "[{\"a\":1},{\"a\":2}]");
922
923 // Whatever survives is valid UTF-8, because it lands in a JavaScript source
924 // position.
925 const json = try shapeRecording(testing.allocator, torn);
926 defer testing.allocator.free(json);
927 try testing.expect(std.unicode.utf8ValidateSlice(json));
928}
929
930test "non-ASCII in a sample is carried through byte for byte" {
931 // Nothing in the recorder writes text today, but a native app appending its
932 // own annotated samples would, and re-encoding is how a bridge loses an
933 // accent.
934 try expectShape(
935 "{\"note\":\"caf\u{00e9} \u{1f4cd}\"}\n",
936 "[{\"note\":\"caf\u{00e9} \u{1f4cd}\"}]",
937 );
938}
939
940test "the shaped reply always parses back as an array of objects" {
941 // The property that makes echoing raw bytes safe: whatever comes out is
942 // JSON, and it is the array shape the page destructures.
943 const text = real_sample ++ "\n" ++ real_sample ++ "\n[1,2]\n\n" ++ real_sample ++ "\n";
944 const json = try shapeRecording(testing.allocator, text);
945 defer testing.allocator.free(json);
946
947 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
948 defer parsed.deinit();
949
950 try testing.expect(parsed.value == .array);
951 try testing.expectEqual(@as(usize, 3), parsed.value.array.items.len);
952 for (parsed.value.array.items) |item| {
953 try testing.expect(item == .object);
954 }
955}
956
957test "extreme magnitudes survive verbatim, with no formatting buffer to overflow" {
958 // The doubles hazard, and why this module has no float formatting at all.
959 //
960 // A sample is eight f64s. Re-rendering one with `{d}` needs up to
961 // `std.fmt.float.bufferSize(.decimal, f64)` bytes *per field* — 1e300 is
962 // 301 digits — so a `[64]u8` would turn a legal sample into a NoSpaceLeft
963 // refusal, and a rounded re-render would report a different position than
964 // the device recorded. Copying the recorded bytes has neither failure mode,
965 // and that is asserted here rather than asserted about.
966 try testing.expect(std.fmt.float.bufferSize(.decimal, f64) > 300);
967
968 var line: std.ArrayListUnmanaged(u8) = .empty;
969 defer line.deinit(testing.allocator);
970 try line.appendSlice(testing.allocator, "{\"latitude\":1");
971 try line.appendNTimes(testing.allocator, '0', 300);
972 try line.appendSlice(testing.allocator, ",\"speed\":-1.7976931348623157e+308}");
973
974 const json = try shapeRecording(testing.allocator, line.items);
975 defer testing.allocator.free(json);
976
977 try testing.expect(json.len > 300);
978 try testing.expectEqualStrings("[", json[0..1]);
979 try testing.expectEqualStrings(line.items, json[1 .. json.len - 1]);
980
981 // And the digits are the recorded digits, not a rounded re-render.
982 try testing.expect(std.mem.indexOf(u8, json, "-1.7976931348623157e+308") != null);
983
984 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
985 defer parsed.deinit();
986 try testing.expectEqual(@as(usize, 1), parsed.value.array.items.len);
987}
988
989test "a magnitude no f64 can hold is carried rather than rounded or dropped" {
990 // `std.json` parses `1e400` into `.number_string`, keeping the source token
991 // because no f64 holds it. Rendering it through `{d}` would emit `inf`,
992 // which is not JSON; dropping the line would lose a sample. The bytes go
993 // through untouched, which is both valid JSON and the only lossless answer.
994 try expectShape(
995 "{\"altitude\":1e400,\"latitude\":99999999999999999999}\n",
996 "[{\"altitude\":1e400,\"latitude\":99999999999999999999}]",
997 );
998}
999
1000test "a sample carrying a quote or a backslash is not escaped twice" {
1001 // The recorded line is already JSON. Escaping it again on the way out —
1002 // which is what building the reply by string concatenation would do — turns
1003 // `"a\"b"` into a different string. This is the bridge_iap.zig failure in a
1004 // different place, and copying bytes is what makes it impossible.
1005 try expectShape(
1006 "{\"note\":\"a\\\"b\\\\c\"}\n",
1007 "[{\"note\":\"a\\\"b\\\\c\"}]",
1008 );
1009}
1010
1011test "shaping does not choke on a large track" {
1012 // 5,000 real samples, which is about ninety minutes of recording. The
1013 // ArrayList growth path and the per-line parse both run; a quadratic or
1014 // fixed-buffer implementation shows up here.
1015 var text: std.ArrayListUnmanaged(u8) = .empty;
1016 defer text.deinit(testing.allocator);
1017 var i: usize = 0;
1018 while (i < 5000) : (i += 1) {
1019 try text.appendSlice(testing.allocator, real_sample);
1020 try text.append(testing.allocator, '\n');
1021 }
1022
1023 const json = try shapeRecording(testing.allocator, text.items);
1024 defer testing.allocator.free(json);
1025
1026 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1027 defer parsed.deinit();
1028 try testing.expectEqual(@as(usize, 5000), parsed.value.array.items.len);
1029}
1030
1031// -----------------------------------------------------------------------------
1032// The filesystem tier — a real file, on every platform.
1033// -----------------------------------------------------------------------------
1034
1035/// A file this test owns, in the working directory, named so it cannot collide
1036/// with the recorder's own track. Never `<AppSupport>/craft-location-recording.jsonl`:
1037/// creating that on a developer's machine would be inventing a recording.
1038const test_track_name = "craft-locrecording-test-track.jsonl";
1039
1040fn writeTestTrack(contents: []const u8) !void {
1041 const io = io_context.get();
1042 const file = try std.Io.Dir.cwd().createFile(io, test_track_name, .{});
1043 defer file.close(io);
1044 try file.writeStreamingAll(io, contents);
1045}
1046
1047fn removeTestTrack() void {
1048 std.Io.Dir.cwd().deleteFile(io_context.get(), test_track_name) catch |err| {
1049 std.log.debug("locrecording test track cleanup failed: {}", .{err});
1050 };
1051}
1052
1053test "a missing track file reads as null, which is the only route to []" {
1054 // Null and "empty file" are different states that give the same answer, and
1055 // every *other* failure gives a different one. This pins the first half.
1056 const missing = try readTrackBytes(testing.allocator, "craft-locrecording-no-such-file.jsonl");
1057 try testing.expect(missing == null);
1058}
1059
1060test "a real track file round-trips from disk to the page's array" {
1061 try writeTestTrack(real_sample ++ "\n" ++ real_sample ++ "\n");
1062 defer removeTestTrack();
1063
1064 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1065 return error.TrackFileVanished;
1066 defer testing.allocator.free(text);
1067
1068 const json = try shapeRecording(testing.allocator, text);
1069 defer testing.allocator.free(json);
1070
1071 try testing.expectEqualStrings("[" ++ real_sample ++ "," ++ real_sample ++ "]", json);
1072}
1073
1074test "a zero-byte track file is [] and not an error" {
1075 // `startLocationRecording` truncates the track before the first fix
1076 // arrives, so this is the normal state of a recording in progress.
1077 try writeTestTrack("");
1078 defer removeTestTrack();
1079
1080 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1081 return error.TrackFileVanished;
1082 defer testing.allocator.free(text);
1083 try testing.expectEqual(@as(usize, 0), text.len);
1084
1085 try expectShape(text, empty_array);
1086}
1087
1088test "a track whose last line is torn keeps every complete sample before it" {
1089 // A crash between `seekToEnd` and the end of `write` leaves exactly this.
1090 try writeTestTrack(real_sample ++ "\n" ++ real_sample ++ "\n{\"latitude\":37.3,\"longi");
1091 defer removeTestTrack();
1092
1093 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1094 return error.TrackFileVanished;
1095 defer testing.allocator.free(text);
1096
1097 const json = try shapeRecording(testing.allocator, text);
1098 defer testing.allocator.free(json);
1099
1100 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1101 defer parsed.deinit();
1102 try testing.expectEqual(@as(usize, 2), parsed.value.array.items.len);
1103}
1104
1105test "a directory where the track should be is an error, never an empty recording" {
1106 // The rule-1 case for the reader. Swift's `try?` answers `[]` for every
1107 // failure alike; telling a page its route is empty when the read failed is
1108 // a wrong answer delivered as a right one.
1109 const io = io_context.get();
1110 const dir_name = "craft-locrecording-test-dir.jsonl";
1111 std.Io.Dir.cwd().createDirPath(io, dir_name) catch |err| switch (err) {
1112 error.PathAlreadyExists => {},
1113 else => return error.SkipZigTest,
1114 };
1115 defer std.Io.Dir.cwd().deleteDir(io, dir_name) catch {};
1116
1117 try testing.expectError(
1118 bridge_error.BridgeError.NativeCallFailed,
1119 readTrackBytes(testing.allocator, dir_name),
1120 );
1121}
1122
1123// -----------------------------------------------------------------------------
1124// The Darwin tier — live Objective-C, read-only.
1125// -----------------------------------------------------------------------------
1126
1127test "the app container path resolves through live Objective-C" {
1128 if (!is_darwin) return error.SkipZigTest;
1129
1130 // `NSFileManager` → `URLsForDirectory:inDomains:` → `firstObject` → `path`,
1131 // for real. On a macOS host this is `~/Library/Application Support`; in the
1132 // app it is the container's. Either way it must be an absolute path, or the
1133 // track file lookup below is relative to whatever the process's cwd happens
1134 // to be — which on iOS is `/`.
1135 const dir = try applicationSupportPath(testing.allocator);
1136 defer testing.allocator.free(dir);
1137
1138 try testing.expect(dir.len > 0);
1139 try testing.expectEqual(@as(u8, '/'), dir[0]);
1140 try testing.expect(dir[dir.len - 1] != '/');
1141 try testing.expect(std.mem.indexOfScalar(u8, dir, 0) == null);
1142}
1143
1144test "the track path is the file the Swift recorder appends to" {
1145 if (!is_darwin) return error.SkipZigTest;
1146
1147 // `locationRecordingTrackURL` puts it directly in Application Support with
1148 // no per-app subdirectory. Reading anywhere else answers `[]` for a
1149 // recording that exists.
1150 const path = try trackFilePath(testing.allocator);
1151 defer testing.allocator.free(path);
1152
1153 try testing.expect(std.mem.endsWith(u8, path, "/craft-location-recording.jsonl"));
1154 try testing.expectEqual(@as(u8, '/'), path[0]);
1155
1156 // And it is *not* the state file, which holds the summary shape this module
1157 // does not serve.
1158 try testing.expect(std.mem.indexOf(u8, path, "craft-location-recording-state.json") == null);
1159}
1160
1161test "with no recording on the host, the handler answers rather than failing" {
1162 if (!is_darwin) return error.SkipZigTest;
1163
1164 const path = try trackFilePath(testing.allocator);
1165 defer testing.allocator.free(path);
1166
1167 // The developer's own machine decides this one: with a track file present
1168 // there is nothing deterministic to assert, and reading it is still safe
1169 // but proves less.
1170 if (try readTrackBytes(testing.allocator, path)) |existing| {
1171 testing.allocator.free(existing);
1172 return error.SkipZigTest;
1173 }
1174
1175 // This runs a handler all the way to `sendResultToJS`, so it logs one
1176 // "failed to send bridge result to JS" warning: there is no webview in a
1177 // test runner. That warning is the evidence the reply was attempted — the
1178 // alternative, asserting only that no error came back, would also pass for
1179 // a handler that returned without replying at all.
1180 var bridge = LocationRecordingBridge.init(testing.allocator);
1181 defer bridge.deinit();
1182
1183 try bridge.handleMessage(A.read_location_recording, "{}");
1184}
packages/zig/src/bridge_mobile_motion.zigadded+1553-0
Changes to packages/zig/src/bridge_mobile_motion.zig
@@ -0,0 +1,1553 @@
1//! The CoreMotion actions of the `mobile` namespace — `startMotionUpdates` and
2//! `stopMotionUpdates` — served here, on Zig's own `CMMotionManager`.
3//!
4//! A previous round of this module served **nothing**, and was right to: the
5//! only way to push an event was `capabilities.Channel`, whose names are all
6//! `craft:`-prefixed and whose own test enforces that prefix, so nothing in Zig
7//! could *spell* `craftMotionUpdate`. Starting a sensor and streaming into a
8//! name no page listens for is fabricated success at the event layer. That
9//! blocker is gone: `ios_events.Event` is the iOS vocabulary taken from Swift's
10//! `sendToWeb` call sites, and `.motion_update` dispatches exactly
11//! `craftMotionUpdate`.
12//!
13//! ## What Swift does, exactly
14//!
15//! `CraftApp.swift:857-865` (dispatcher) and `:3751-3790` (implementation):
16//!
17//! - **`startMotionUpdates` takes one field, `interval`,** a number in
18//! **milliseconds**, defaulting to **100** (`body["interval"] as? Double ??
19//! 100`). `:3758` divides it by 1000 into the `NSTimeInterval` seconds
20//! `deviceMotionUpdateInterval` wants. Android agrees on the name, the unit
21//! and the default (`CraftBridge.kt.template:547`). `craft.d.ts:245`
22//! declares `startMotionUpdates(): void` — no parameter and no promise — and
23//! is wrong on both counts.
24//! - **`stopMotionUpdates` takes nothing.** Swift reads nothing out of `body`;
25//! `ios_dispatch.payloadOf` would hand a handler `"{}"`.
26//! - **Both replies are the bare fragment `true`.** `resolveCallback`
27//! serialises with `.fragmentsAllowed` and both paths pass `result: true`
28//! (`:3784`, `:865`) — so `true`, never `{"ok":true}` and never the object
29//! wrapper `bridge_mobile_watch.zig` needed. `startMotionUpdates` replies
30//! *once*, after the framework call returns, and then the handler block
31//! emits repeatedly; `stopMotionUpdates` replies once and emits nothing.
32//! - **`startMotionUpdates`'s only failure path is a rejection** (`:3753-3755`,
33//! "Motion sensors not available"), never a resolved `false`. It fires when
34//! the manager is nil (the `enableMotionSensors` flag is off) or when
35//! `isDeviceMotionAvailable` is NO.
36//! - **The event is `craftMotionUpdate`** — no `craft:` prefix, no colons —
37//! dispatched through `sendToWeb` (`:4835-4845`), which inlines the JSON as
38//! the `detail` literal exactly as `ios_events.formatEvent` does.
39//! - **The detail has three keys** (`:3764-3780`): `acceleration`
40//! (`CMDeviceMotion.userAcceleration`, x/y/z), `rotation`
41//! (`CMAttitude.yaw/pitch/roll` as alpha/beta/gamma — **attitude in
42//! radians, not gyroscope rate**), and `gravity` (`CMDeviceMotion.gravity`,
43//! x/y/z). `shapeDetail` emits those and only those.
44//! - **The handler queue is `.main`** (`:3761`), i.e. `[NSOperationQueue
45//! mainQueue]`. This uses the same queue, so the callback lands where
46//! Swift's did. `ios_events.emit` is thread-safe regardless — it copies,
47//! queues and hops to the main queue itself — so the choice is about
48//! matching the spec, not about safety.
49//!
50//! ## The three consumers all read keys nothing has ever emitted
51//!
52//! `packages/ios/templates/test-bridges.html:397-402` reads
53//! `e.detail.accelerometer` and `e.detail.gyroscope`; `packages/ios/README.md`
54//! and `craft.d.ts`'s `CraftMotionUpdateEvent` say the same. **Neither iOS nor
55//! Android has ever produced those keys** — Android emits `acceleration` plus a
56//! `rotation` that really is gyro rate, and no `gravity` at all. Every
57//! documented consumer reads `undefined` today.
58//!
59//! The emitter is the contract this migration must preserve — the conformance
60//! test embeds `CraftApp.swift` as the spec — so `shapeDetail` emits Swift's
61//! three keys and a test below pins that it does *not* invent
62//! `accelerometer`/`gyroscope`. Renaming `attitude` to `gyroscope` would be an
63//! outright lie about what the number is. The doc/type/test-page mismatch is a
64//! real, separate, pre-existing bug and is reported rather than papered over.
65//!
66//! Worth knowing while reading any of that: **`window.craft.startMotionUpdates`
67//! does not exist in the injected iOS JavaScript.** `injectNativeBridge`
68//! (`CraftApp.swift:1155`-`2427`) contains no occurrence of "motion" at all,
69//! and `craft-bridge.js` has no `mobile` surface either. So the README example
70//! and the test page's two buttons are `TypeError`s on the legacy path, and the
71//! dispatcher arm is reachable only through the Zig hand-off envelope. That is
72//! a second pre-existing bug, and it is why "what the page consumes" had to be
73//! read off the Swift dispatcher and cross-checked against Android rather than
74//! off an injected JS method that was never written.
75//!
76//! ## The `enableMotionSensors` gate, and why the Info.plist stands in for it
77//!
78//! `CraftApp.swift:859` is `if config.enableMotionSensors { … }` with **no
79//! `else`**, so on the Swift path with the flag off `startMotionUpdates`
80//! replies nothing at all while `CraftSwiftShim.handleAction` still returns
81//! true: the page's promise never settles. The flag defaults to `false`
82//! (`packages/ios/src/index.ts:139`) and has no mirror anywhere under
83//! `packages/zig/src`.
84//!
85//! `NSMotionUsageDescription` is an *exact* proxy for it —
86//! `packages/ios/src/index.ts:192` writes that Info.plist key from
87//! `config.enableMotionSensors` and from nothing else, with no `||` (unlike the
88//! location keys, where `bridge_mobile_location.zig` had to document a residual
89//! gap). So `requireMotionConfigured` reads the key and refuses when it is
90//! absent. Two things that buys, and one it costs:
91//!
92//! - a hang becomes a nameable refusal, which is strictly better than the
93//! shim's silence;
94//! - an app whose author switched motion off does not get motion because Zig
95//! happens to own its own manager and could have ignored the flag.
96//! - The cost is a vocabulary mismatch: Swift rejects with `CRAFT_ERROR` and
97//! the message "Motion sensors not available", while `BridgeError` has no
98//! such member. `PermissionDenied` -> `PERMISSION_DENIED` is what this says
99//! for "the app was not built with motion enabled", and
100//! `PlatformNotSupported` -> `PLATFORM_NOT_SUPPORTED` for "this device has
101//! no device motion". Swift collapsed both into one message; splitting them
102//! tells the page which of the two it is.
103//!
104//! Note that `NSMotionUsageDescription` is not itself required by
105//! `CMMotionManager` for device motion — it is CMPedometer and
106//! CMMotionActivityManager that need it. It is read here purely as evidence of
107//! the build-time flag, and that is the only claim made for it.
108//!
109//! ## `isDeviceMotionAvailable` is checked before anything starts
110//!
111//! It is **NO on the simulator**, which is where most of this will first be
112//! run. Starting anyway would hand the page a stream of zeroes that no sensor
113//! produced — a fabricated reading, which is worse than no reading. So the
114//! availability check happens before the manager is asked to start, and a NO
115//! is an error the page can catch, exactly as Swift's `guard` makes it.
116//!
117//! ## `CMAcceleration` is `{double x, y, z}` — 24 bytes — and the ABI splits
118//!
119//! On aarch64 that is a homogeneous floating aggregate returned in `v0`-`v2`
120//! through plain `objc_msgSend`. On `x86_64` it is >16 bytes, MEMORY class, and
121//! needs the caller-provided-pointer entry point `objc_msgSend_stret`.
122//! `build.zig:1860` builds an x86_64 iOS-simulator target, so both ship.
123//!
124//! `objc_runtime.msgSendStret` is **wrong for this**: it selects by `@sizeOf`
125//! alone and would reference `objc_msgSend_stret` on arm64, which arm64 libobjc
126//! does not export — an undefined symbol at app link. `acceleration_entry`
127//! below is the comptime `builtin.target.cpu.arch` choice
128//! `menubar_collapse.zig:117-128` already makes for `-frame`, and a test pins
129//! it. The doubles (`yaw`/`pitch`/`roll`) go through plain `objc_msgSend` on
130//! both arches; `objc_msgSend_fpret` is an x86_64 `long double` concern only
131//! and is not used.
132//!
133//! ## The handler is a module-level GLOBAL block
134//!
135//! `startDeviceMotionUpdatesToQueue:withHandler:` *escapes* its block: it
136//! `Block_copy`s it and calls it repeatedly for as long as updates run. A stack
137//! block would be a dangling call the moment the dispatch frame returned. The
138//! block here is file-scope with `isa = &_NSConcreteGlobalBlock` and
139//! `BLOCK_IS_GLOBAL` set, so `Block_copy` is the identity function — the
140//! pattern `ios_async.zig` and `bridge_mobile_haptics.zig` both use.
141//!
142//! It captures nothing, because it cannot: a global block has no capture
143//! storage. Everything it needs — the six selectors it sends — lives in
144//! `stream`, published under a mutex *before* the framework call, so a handler
145//! that somehow ran before `msgSend` returned would still find them.
146//!
147//! `ios_async` is deliberately **not** used. Its slot pool answers *one* call
148//! with *one* reply; this handler is a repeating stream that never replies at
149//! all. `startMotionUpdates`'s single reply is synchronous, from inside the
150//! dispatch frame, where `request_context` still names the call — so there is
151//! no late reply to correlate and nothing for a ticket to hold.
152//!
153//! ## Restart, and what `stopMotionUpdates` may honestly claim
154//!
155//! CoreMotion does not define what a second
156//! `startDeviceMotionUpdatesToQueue:withHandler:` does while updates are
157//! already running, and Swift calls it again regardless. This stops first when
158//! `stream` says updates are live, so a page that changes `interval` ends with
159//! exactly one stream at the rate it asked for.
160//!
161//! `stopMotionUpdates` replies `true` even when no manager was ever built,
162//! which mirrors Swift's `motionManager?.stopDeviceMotionUpdates()` — an
163//! optional-chained no-op that still resolves `true`. That is not a fabricated
164//! success: the postcondition the page asked for ("no motion updates are being
165//! delivered") holds. It holds *because* this module serves `startMotionUpdates`
166//! too — the earlier round could not have said this, since a Zig-only
167//! `stopMotionUpdates` would have stopped its own null manager while Swift's
168//! kept streaming. Either both actions are served or neither is; both are.
169//!
170//! ## What is pure and what is not
171//!
172//! `intervalSeconds` and `shapeDetail` touch no Objective-C and no state, and
173//! they are the two places the observable contract can silently go wrong — a
174//! dropped `interval`, a renamed detail key, a `[64]u8` that turns a legal
175//! double into `NoSpaceLeft`. They are pinned by host tests that run on every
176//! platform. The CoreMotion half is exercised as far as a host honestly can:
177//! selector interning, the block's global flags, the ABI entry-point choice,
178//! the refusal paths, and — measured, not argued — `isDeviceMotionAvailable`
179//! answering NO on a machine with no device-motion service.
180//!
181//! One correction worth carrying: `CMMotionManager.h` is
182//! `API_UNAVAILABLE(macos)`, and an earlier note in this module concluded from
183//! that that `objc_getClass("CMMotionManager")` is null on macOS. It is not —
184//! the class is present on a macOS runner. So the class's absence is not a
185//! guard anywhere in this file; `ensureManager` guards the lookup because a
186//! lookup should be guarded, and `isDeviceMotionAvailable` is what actually
187//! refuses a machine without sensors. No test constructs the module-level
188//! manager: `manager` staying null is asserted, so a later change that starts
189//! building one inside the test runner fails loudly.
190
191const std = ;
192const builtin = ;
193const capabilities = ;
194const bridge_error = ;
195const objc_runtime = ;
196const ios_events = ;
197const compat_mutex = ;
198
199const objc = objc_runtime.objc;
200const is_darwin = builtin.target.os.tag.isDarwin();
201
202/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
203/// location/notifications precedent: `objc_runtime.objc` is an empty struct off
204/// Darwin and a function *signature* is analysed even when a comptime platform
205/// guard prunes the body, so naming `objc.id` in the `callconv(.c)` types below
206/// would break the host build. A single optional pointer, never `?objc.id` — a
207/// double optional is illegal in `callconv(.c)`.
208const Id = ?*anyopaque;
209
210/// The action names, spelled exactly as the Swift `case` labels spell them.
211/// `test/ios_conformance_test.zig` matches this block against those labels in
212/// both directions, and fails the build if two modules declare one name.
213pub const A = struct {
214 pub const start_motion_updates = "startMotionUpdates";
215 pub const stop_motion_updates = "stopMotionUpdates";
216};
217
218/// `.result` for both: each terminates in exactly one `sendResultToJS` or one
219/// error. `.none` would tell an app not to await a call that does reply, and a
220/// declared `.result` that never replied would park the caller until the
221/// page's timeout.
222///
223/// `.live` for both: they dispatch and do the thing. `.unavailable` is for an
224/// action that dispatches and refuses, which neither is — a refusal here is a
225/// specific condition (motion not configured, no device motion, CoreMotion
226/// absent), never the action's normal answer.
227pub const capability_actions = [_]capabilities.ActionDecl{
228 .{ .name = A.start_motion_updates, .reply = .result },
229 .{ .name = A.stop_motion_updates, .reply = .result },
230};
231
232/// The event the stream carries, spelled as `sendToWeb` spells it.
233///
234/// No `craft:` prefix and no colon: this is the iOS vocabulary, and it is why
235/// `ios_events.Event` exists separately from `capabilities.Channel`. Emitting
236/// a `craft:`-style name here would fire an event with no subscriber.
237pub const event: ios_events.Event = .motion_update;
238
239/// Pinned as a literal so a test can compare it against `event.eventName()`
240/// without the comparison being a tautology over one constant.
241pub const event_name = "craftMotionUpdate";
242
243/// Swift's default when `interval` is absent: 100 **milliseconds**
244/// (`CraftApp.swift:860`). Android's injected JS defaults identically.
245pub const default_interval_ms: f64 = 100;
246
247/// Both replies, as the bare JSON fragment `resolveCallback(…, result: true)`
248/// produces under `.fragmentsAllowed`. Not `{"ok":true}`.
249const reply_true = "true";
250
251/// Emitted into Info.plist by `packages/ios/src/index.ts:192` iff
252/// `config.enableMotionSensors` — the flag with no Zig mirror. See the module
253/// comment for what this proxy claims and what it does not.
254const key_motion_usage = "NSMotionUsageDescription";
255
256/// Which handler an action selects, split out from `handleMessage` so the
257/// table-versus-dispatch agreement is assertable on a host without touching
258/// CoreMotion.
259const Route = enum { start, stop };
260
261fn routeFor(action: []const u8) ?Route {
262 if (std.mem.eql(u8, action, A.start_motion_updates)) return .start;
263 if (std.mem.eql(u8, action, A.stop_motion_updates)) return .stop;
264 return null;
265}
266
267pub const MotionBridge = struct {
268 allocator: std.mem.Allocator,
269
270 const Self = @This();
271
272 pub fn init(allocator: std.mem.Allocator) Self {
273 return .{ .allocator = allocator };
274 }
275
276 pub fn deinit(_: *Self) void {}
277
278 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
279 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
280 // Exhaustive, so a `Route` without a handler is a compile error.
281 return switch (route) {
282 .start => self.startMotionUpdates(data),
283 .stop => self.stopMotionUpdates(data),
284 };
285 }
286
287 /// `case "startMotionUpdates"` (`CraftApp.swift:858-862`, `:3752-3785`).
288 ///
289 /// Every fallible step runs before the framework call, so the reply is
290 /// sent only once there is nothing left that can fail: payload, config
291 /// gate, six handler selectors, four dispatcher selectors, the operation
292 /// queue, the manager, and `isDeviceMotionAvailable`. `startDeviceMotion…`
293 /// itself returns void and cannot report anything.
294 fn startMotionUpdates(self: *Self, data: []const u8) !void {
295 if (!is_darwin) return error.UnsupportedPlatform;
296
297 const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch
298 return bridge_error.BridgeError.InvalidJSON;
299 defer parsed.deinit();
300 const seconds = try intervalSeconds(parsed.value);
301
302 try requireMotionConfigured();
303
304 // Resolved here, not in the handler: the handler runs after this frame
305 // is gone, where a `sel_registerName` failure could only be logged.
306 const sels = try Sels.resolve();
307
308 const sel_available = try selector("isDeviceMotionAvailable");
309 const sel_set_interval = try selector("setDeviceMotionUpdateInterval:");
310 const sel_start = try selector("startDeviceMotionUpdatesToQueue:withHandler:");
311 const sel_stop = try selector("stopDeviceMotionUpdates");
312 const queue = try mainOperationQueue();
313
314 const mgr = try ensureManager();
315
316 if (!objc.msgSendBool(mgr, sel_available)) {
317 // The simulator's answer, and some hardware's. Swift's `guard`
318 // rejects here too. Starting anyway would emit zeroes that no
319 // sensor measured, which is the one outcome worse than an error.
320 std.log.warn(
321 "startMotionUpdates refused: isDeviceMotionAvailable is NO — there is no " ++
322 "device-motion service here (a simulator reports this), and a stream of " ++
323 "zeroes would be readings nothing took",
324 .{},
325 );
326 return bridge_error.BridgeError.PlatformNotSupported;
327 }
328
329 // CoreMotion does not define a second start over a live one. Stopping
330 // first makes a re-`start` with a new interval mean exactly one stream
331 // at the new rate.
332 if (isStreaming()) objc.msgSend(mgr, sel_stop);
333
334 const IntervalFn = *const fn (Id, Id, f64) callconv(.c) void;
335 const set_interval: IntervalFn = @ptrCast(&objc.objc_msgSend);
336 set_interval(mgr, sel_set_interval, seconds);
337
338 // Armed before the framework call, never after: the handler is a
339 // global block with no captures, so `stream` is the only place it can
340 // read its selectors from, and one that fired before `msgSend`
341 // returned would otherwise drop the first sample.
342 armStream(sels);
343
344 const StartFn = *const fn (Id, Id, Id, *const anyopaque) callconv(.c) void;
345 const start: StartFn = @ptrCast(&objc.objc_msgSend);
346 start(mgr, sel_start, queue, @ptrCast(&motion_block));
347
348 bridge_error.sendResultToJS(self.allocator, A.start_motion_updates, reply_true);
349 }
350
351 /// `case "stopMotionUpdates"` (`CraftApp.swift:863-865`, `:3787-3790`).
352 ///
353 /// `data` is accepted and ignored, exactly as Swift reads nothing out of
354 /// `body`. Parsing it would invent a way for the call to fail that the
355 /// spec does not have — `ios_dispatch.payloadOf` hands this `"{}"` anyway.
356 ///
357 /// Disarming happens whether or not a manager exists, and it happens
358 /// *before* the framework call rather than after. A handler block already
359 /// queued on the main queue can still run once
360 /// `stopDeviceMotionUpdates` has returned; with `stream` cleared it finds
361 /// no selectors and emits nothing, so "stopped" means stopped from the
362 /// page's side of the bridge and not merely from CoreMotion's. Ordering it
363 /// first is what makes that true without assuming this dispatch and the
364 /// handler share the main thread — the same assumption `stream_mutex`
365 /// declines to make.
366 fn stopMotionUpdates(self: *Self, data: []const u8) !void {
367 if (!is_darwin) return error.UnsupportedPlatform;
368 _ = data;
369
370 disarmStream();
371
372 if (manager) |mgr| {
373 const sel_stop = try selector("stopDeviceMotionUpdates");
374 objc.msgSend(mgr, sel_stop);
375 }
376
377 bridge_error.sendResultToJS(self.allocator, A.stop_motion_updates, reply_true);
378 }
379};
380
381// =============================================================================
382// The config gate.
383// =============================================================================
384
385/// Refuse unless the app was built with `enableMotionSensors`.
386///
387/// See the module comment for why the Info.plist key is an exact proxy for the
388/// flag and what that does and does not claim.
389fn requireMotionConfigured() !void {
390 if (!is_darwin) return error.UnsupportedPlatform;
391
392 if (try infoPlistHas(key_motion_usage)) return;
393
394 std.log.warn(
395 "startMotionUpdates refused: Info.plist has no {s}, so this app was not built with " ++
396 "motion sensors enabled",
397 .{key_motion_usage},
398 );
399 return bridge_error.BridgeError.PermissionDenied;
400}
401
402/// Whether the main bundle's Info.plist carries `key`.
403///
404/// Errors rather than answering `false` when the runtime itself will not
405/// cooperate: "there is no NSBundle class" and "this app did not ask for
406/// motion" are different facts, and collapsing them would blame the app's
407/// configuration for a broken process. Every runtime result is guarded.
408fn infoPlistHas(comptime key: [*:0]const u8) !bool {
409 if (!is_darwin) return error.UnsupportedPlatform;
410
411 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
412 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
413 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoMainBundle;
414
415 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
416 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
417 return error.SelectorNotFound;
418 const ns_key = objc.msgSendId1(NSString, sel_string, key) orelse return error.NativeCallFailed;
419
420 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
421 return error.SelectorNotFound;
422 return objc.msgSendId1(bundle, sel_lookup, ns_key) != null;
423}
424
425// =============================================================================
426// The manager and the queue.
427// =============================================================================
428
429fn selector(name: [*:0]const u8) !Id {
430 if (!is_darwin) return error.UnsupportedPlatform;
431 return objc.sel_registerName(name) orelse error.SelectorNotFound;
432}
433
434/// The one `CMMotionManager` this module owns.
435///
436/// Module-level because `ios_dispatch` builds a fresh `MotionBridge` per
437/// dispatch and drops it again: a manager held on the bridge would be released
438/// the instant `startMotionUpdates` returned, and the stream with it. Swift's
439/// is an ivar on a coordinator that outlives every call, for the same reason.
440var manager: Id = null;
441
442fn ensureManager() !*anyopaque {
443 if (!is_darwin) return error.UnsupportedPlatform;
444 if (manager) |existing| return existing;
445
446 const CMMotionManager = objc.objc_getClass("CMMotionManager") orelse {
447 // Not a device without sensors — a process without CoreMotion at
448 // all. The generated app always has it (`CraftApp.swift:15` is
449 // `import CoreMotion`, and swiftc links what it imports). A bare Zig
450 // binary that has loaded nothing else does not; a host test binary
451 // linking Cocoa and WebKit does, transitively — which is why the
452 // class's presence is never used as a proxy for anything.
453 std.log.warn(
454 "startMotionUpdates refused: there is no CMMotionManager class in this process, " ++
455 "so CoreMotion is not loaded here",
456 .{},
457 );
458 return bridge_error.BridgeError.PlatformNotSupported;
459 };
460
461 const created = (try objc.allocInit(CMMotionManager)) orelse return error.NativeCallFailed;
462 manager = created;
463 return created;
464}
465
466/// `[NSOperationQueue mainQueue]` — Swift's `to: .main` (`CraftApp.swift:3761`).
467fn mainOperationQueue() !*anyopaque {
468 if (!is_darwin) return error.UnsupportedPlatform;
469
470 const NSOperationQueue = objc.objc_getClass("NSOperationQueue") orelse
471 return error.ClassNotFound;
472 const sel_main = try selector("mainQueue");
473 return objc.msgSendId(NSOperationQueue, sel_main) orelse error.NativeCallFailed;
474}
475
476// =============================================================================
477// The selectors the handler needs, resolved while an error is still deliverable.
478// =============================================================================
479
480/// Everything the handler block will send.
481///
482/// Copied into `stream` rather than looked up per sample: at 100 Hz this is a
483/// hot path, and — more importantly — a `sel_registerName` failure inside the
484/// handler could only be logged, never turned into an answer for anybody.
485const Sels = struct {
486 user_acceleration: Id,
487 gravity: Id,
488 attitude: Id,
489 roll: Id,
490 pitch: Id,
491 yaw: Id,
492
493 fn resolve() !Sels {
494 if (!is_darwin) return error.UnsupportedPlatform;
495 return .{
496 .user_acceleration = try selector("userAcceleration"),
497 .gravity = try selector("gravity"),
498 .attitude = try selector("attitude"),
499 .roll = try selector("roll"),
500 .pitch = try selector("pitch"),
501 .yaw = try selector("yaw"),
502 };
503 }
504};
505
506// =============================================================================
507// The stream's state. The handler captures nothing, so this is where it reads.
508// =============================================================================
509
510const Stream = struct {
511 /// Non-null exactly while updates are meant to be delivered. The handler
512 /// reads its selectors here, and a null is what makes a late callback a
513 /// no-op instead of an event after `stopMotionUpdates`.
514 sels: ?Sels = null,
515 /// One fault line per start.
516 ///
517 /// A handler that fails at 100 Hz would otherwise write a hundred log
518 /// lines a second and drown the thing being diagnosed — the same reason
519 /// `ios_events` counts drops instead of logging each one. The first fault
520 /// after each start is logged with its reason; the rest are silent.
521 fault_logged: bool = false,
522};
523
524var stream: Stream = .{};
525
526/// Guarded even though both sides normally run on the main thread — the
527/// dispatch is a WebKit main-thread callback and the handler is on
528/// `mainQueue`. "It should always be the main thread" is not a guard, and
529/// `NSOperationQueue.mainQueue` is a promise about *scheduling*, not one this
530/// module can enforce.
531var stream_mutex: compat_mutex.Mutex = .{};
532
533fn armStream(sels: Sels) void {
534 stream_mutex.lock();
535 defer stream_mutex.unlock();
536 stream = .{ .sels = sels, .fault_logged = false };
537}
538
539fn disarmStream() void {
540 stream_mutex.lock();
541 defer stream_mutex.unlock();
542 stream = .{};
543}
544
545fn streamSels() ?Sels {
546 stream_mutex.lock();
547 defer stream_mutex.unlock();
548 return stream.sels;
549}
550
551fn isStreaming() bool {
552 stream_mutex.lock();
553 defer stream_mutex.unlock();
554 return stream.sels != null;
555}
556
557/// True the first time it is called since the last `armStream`.
558fn claimFaultLog() bool {
559 stream_mutex.lock();
560 defer stream_mutex.unlock();
561 if (stream.fault_logged) return false;
562 stream.fault_logged = true;
563 return true;
564}
565
566// =============================================================================
567// The handler block. Global, so `Block_copy` is the identity function.
568// =============================================================================
569
570const BlockDescriptor = extern struct {
571 reserved: c_ulong = 0,
572 size: c_ulong,
573};
574
575/// `CMDeviceMotionHandler` — `void (^)(CMDeviceMotion *motion, NSError *error)`.
576const MotionBlock = extern struct {
577 isa: ?*anyopaque,
578 flags: c_int,
579 reserved: c_int = 0,
580 invoke: *const fn (*const anyopaque, Id, Id) callconv(.c) void,
581 descriptor: *const BlockDescriptor,
582};
583
584/// 1 << 28. A global block is never copied: `Block_copy` returns the same
585/// pointer. No heap block, no copy/dispose pair, no descriptor lifetime — which
586/// is what makes it safe to hand to an API that holds it for the life of the
587/// stream.
588const BLOCK_IS_GLOBAL: c_int = 1 << 28;
589
590extern var _NSConcreteGlobalBlock: anyopaque;
591
592const motion_block_descriptor = BlockDescriptor{ .size = @sizeOf(MotionBlock) };
593
594/// Guarded on `is_darwin` the way `ios_async.zig` guards its pool: off Darwin
595/// the initialiser would name `_NSConcreteGlobalBlock`, which is a symbol only
596/// libsystem_blocks has.
597const motion_block: MotionBlock = if (is_darwin) .{
598 .isa = &_NSConcreteGlobalBlock,
599 .flags = BLOCK_IS_GLOBAL,
600 .invoke = motionHandler,
601 .descriptor = &motion_block_descriptor,
602} else undefined;
603
604/// One device-motion callback: read, shape, emit. Never reply — there is no
605/// call waiting on it.
606///
607/// Three ways out without an event, and none of them invents one:
608///
609/// - **not armed** — a callback that outlived `stopMotionUpdates`, or one for
610/// a stream this module never started. Silent: it is the expected shape of
611/// a queued block draining after a stop, not a fault.
612/// - **no sample** — Swift's `guard let motion = motion else { return }`. The
613/// iOS event vocabulary has no `craftMotionError`, so there is nobody to
614/// tell; emitting zeroes instead would be a reading nothing took.
615/// - **unshapeable** — a nil `attitude`, or a non-finite double.
616/// `ios_events.formatEvent` inlines the detail as a raw JSON literal, so a
617/// `nan` would not produce a bad number, it would produce a `dispatchEvent`
618/// call the page cannot parse and take the whole event with it. Dropping
619/// one sample is the smaller loss.
620fn motionHandler(_: *const anyopaque, motion: Id, err: Id) callconv(.c) void {
621 if (!is_darwin) return;
622
623 const sels = streamSels() orelse return;
624
625 if (motion == null) {
626 if (claimFaultLog()) {
627 std.log.warn(
628 "craftMotionUpdate: CoreMotion delivered no sample (error={s}); no event is " ++
629 "emitted for a reading that was not taken. Further faults in this stream " ++
630 "are not logged",
631 .{readNSString(err, "localizedDescription") orelse "(none)"},
632 );
633 }
634 return;
635 }
636
637 const sample = readSample(motion, sels) catch |read_err| {
638 if (claimFaultLog()) {
639 std.log.warn(
640 "craftMotionUpdate: could not read the CMDeviceMotion ({}); dropping the " ++
641 "sample. Further faults in this stream are not logged",
642 .{read_err},
643 );
644 }
645 return;
646 };
647
648 const allocator = std.heap.c_allocator;
649 const detail = shapeDetail(allocator, sample) catch |shape_err| {
650 if (claimFaultLog()) {
651 std.log.warn(
652 "craftMotionUpdate: could not shape the detail ({}); dropping the sample. " ++
653 "Further faults in this stream are not logged",
654 .{shape_err},
655 );
656 }
657 return;
658 };
659 defer allocator.free(detail);
660
661 // Safe from any thread: it copies, queues and hops to the main queue
662 // itself. Never `evaluateJavaScript` from here.
663 ios_events.emit(event, detail);
664}
665
666/// `CMAcceleration` — `typedef struct { double x, y, z; }`, 24 bytes.
667///
668/// Kept separate from the wire-shaped `Vector3` so the ABI type and the JSON
669/// type can be reasoned about independently; they happen to have the same
670/// three fields in the same order, and the conversion below is where that
671/// coincidence is stated rather than assumed.
672const CMAcceleration = extern struct { x: f64, y: f64, z: f64 };
673
674/// The bare shape of an `objc_msgSend` symbol, before it is cast to the
675/// signature of the message being sent.
676const MsgSendEntry = *const fn () callconv(.c) void;
677
678/// The `objc_msgSend` entry point a 24-byte struct return needs on this arch.
679///
680/// x86_64 classes anything over 16 bytes as MEMORY and returns it through a
681/// caller-provided pointer, which is `objc_msgSend_stret`. arm64 routes every
682/// return shape through `objc_msgSend` and does not export the `_stret`
683/// variant at all — naming it there is an undefined symbol at app link, which
684/// is exactly why `objc_runtime.msgSendStret` (which selects on `@sizeOf`
685/// alone) must not be used for this.
686const acceleration_entry: MsgSendEntry = if (!is_darwin)
687 undefined
688else if (builtin.target.cpu.arch == .x86_64)
689 &objc.objc_msgSend_stret
690else
691 &objc.objc_msgSend;
692
693/// Read one `CMDeviceMotion` into plain doubles.
694///
695/// `objc_msgSend_fpret` is not used and must not be: it is `long double`-only
696/// on x86_64 and absent on arm64, so a `double`-returning property goes through
697/// the plain symbol. `macos.zig`'s `msgSend0Double` does the same.
698fn readSample(motion: Id, sels: Sels) !MotionSample {
699 if (!is_darwin) return error.UnsupportedPlatform;
700 if (motion == null) return error.NativeCallFailed;
701
702 const IdFn = *const fn (Id, Id) callconv(.c) Id;
703 const send_id: IdFn = @ptrCast(&objc.objc_msgSend);
704
705 const DoubleFn = *const fn (Id, Id) callconv(.c) f64;
706 const send_double: DoubleFn = @ptrCast(&objc.objc_msgSend);
707
708 const VectorFn = *const fn (Id, Id) callconv(.c) CMAcceleration;
709 const send_vector: VectorFn = @ptrCast(acceleration_entry);
710
711 // `attitude` is an object, and a nil one would make the three doubles
712 // below `objc_msgSend` to nil — which returns 0.0 rather than crashing, so
713 // an unguarded read would emit a flat, plausible, entirely invented
714 // orientation.
715 const attitude = send_id(motion, sels.attitude) orelse return error.NoAttitudeInSample;
716
717 const acceleration = send_vector(motion, sels.user_acceleration);
718 const gravity = send_vector(motion, sels.gravity);
719
720 return .{
721 .acceleration = .{ .x = acceleration.x, .y = acceleration.y, .z = acceleration.z },
722 // Swift's three renames, performed visibly: yaw -> alpha, pitch ->
723 // beta, roll -> gamma (`CraftApp.swift:3771-3773`).
724 .rotation = .{
725 .alpha = send_double(attitude, sels.yaw),
726 .beta = send_double(attitude, sels.pitch),
727 .gamma = send_double(attitude, sels.roll),
728 },
729 .gravity = .{ .x = gravity.x, .y = gravity.y, .z = gravity.z },
730 };
731}
732
733/// A zero-argument `NSString`-returning property, as bytes. Null for a nil
734/// object, a nil string, or a selector that will not register — all three are
735/// "nothing to log", never a crash. The slice borrows the string's internal
736/// buffer, which is valid for the current autorelease pool; the only caller
737/// logs it immediately.
738fn readNSString(object: Id, comptime name: [*:0]const u8) ?[]const u8 {
739 if (!is_darwin) return null;
740 if (object == null) return null;
741
742 const sel = objc.sel_registerName(name) orelse return null;
743 const value = objc.msgSendId(object, sel) orelse return null;
744 const utf8 = objc.getNSStringUTF8(value) orelse return null;
745 return std.mem.span(utf8);
746}
747
748// =============================================================================
749// The payload. Pure, and pinned before anything can drift.
750// =============================================================================
751
752/// The `interval` field, converted to the seconds
753/// `deviceMotionUpdateInterval` wants.
754///
755/// Field name, unit and default all come from the Swift dispatcher
756/// (`body["interval"] as? Double ?? 100`, then `interval / 1000.0`),
757/// cross-checked against Android's injected JS, because iOS's injected JS never
758/// defined the method at all.
759///
760/// Two deliberate divergences from `as? Double`, both in the direction of
761/// saying something rather than guessing:
762///
763/// - **A present-but-not-numeric `interval` is refused.** Swift silently falls
764/// back to 100, and — because Foundation bridges `__NSCFBoolean` to
765/// `NSNumber` — turns `{"interval":true}` into a 1 ms interval, which is a
766/// very expensive way to mistype a number. Refusing is the same call
767/// `bridge_mobile_watch.zig` documents for its missing-`context` hang.
768/// - **A non-finite `interval` is refused.** JSON has no `NaN` literal, but
769/// `1e400` parses to `.number_string` and then to `inf`, and an infinite
770/// `NSTimeInterval` is undefined behaviour inside CoreMotion rather than a
771/// slow update.
772///
773/// An *absent* `interval` is not a divergence: 100 ms is the real contract and
774/// is honoured.
775pub fn intervalSeconds(payload: std.json.Value) !f64 {
776 const object = switch (payload) {
777 .object => |o| o,
778 else => return bridge_error.BridgeError.InvalidJSON,
779 };
780
781 const ms: f64 = if (object.get("interval")) |field| switch (field) {
782 .integer => |i| @floatFromInt(i),
783 .float => |f| f,
784 // `std.json` parks a literal that overflows `i64`, or parses
785 // non-finite, in `.number_string` with the scanner-validated source
786 // bytes. It is still a number the page sent, so it is read rather than
787 // refused for its spelling; the finiteness check below is what catches
788 // `1e400`.
789 .number_string => |s| std.fmt.parseFloat(f64, s) catch
790 return bridge_error.BridgeError.InvalidParameter,
791 else => return bridge_error.BridgeError.InvalidParameter,
792 } else default_interval_ms;
793
794 if (!std.math.isFinite(ms)) return bridge_error.BridgeError.InvalidParameter;
795 return ms / 1000.0;
796}
797
798// =============================================================================
799// The event detail. Pure, and the bytes are the contract.
800// =============================================================================
801
802/// One three-axis reading, in the key spelling Swift uses for `acceleration`
803/// and `gravity`.
804pub const Vector3 = struct { x: f64, y: f64, z: f64 };
805
806/// Swift's `rotation`: `CMAttitude`'s yaw, pitch and roll, **in radians**,
807/// renamed to alpha/beta/gamma on the way out.
808///
809/// Named `Attitude` rather than `Rotation` so nothing in Zig can mistake it for
810/// `CMDeviceMotion.rotationRate`, which is the gyroscope and which Swift never
811/// reads. The wire key stays `rotation`, because that is what ships.
812pub const Attitude = struct {
813 /// `CMAttitude.yaw` (`CraftApp.swift:3771`).
814 alpha: f64,
815 /// `CMAttitude.pitch` (`:3772`).
816 beta: f64,
817 /// `CMAttitude.roll` (`:3773`).
818 gamma: f64,
819};
820
821/// Everything one `craftMotionUpdate` carries.
822pub const MotionSample = struct {
823 /// `CMDeviceMotion.userAcceleration` — G's, gravity already removed.
824 acceleration: Vector3,
825 /// `CMDeviceMotion.attitude` — radians.
826 rotation: Attitude,
827 /// `CMDeviceMotion.gravity` — G's.
828 gravity: Vector3,
829};
830
831/// One JSON number.
832///
833/// The buffer is `std.fmt.float.bufferSize(.decimal, f64)` — 347 bytes — and
834/// not a byte less. `{d}` renders decimal notation, never scientific, so
835/// `f64` extremes are long: `1e300` is 301 characters and the smallest
836/// subnormal is 326. A `[64]u8` here would turn a legal reading into
837/// `NoSpaceLeft`, which is the kind of failure that only shows up on a device.
838///
839/// A non-finite value is refused rather than printed. `ios_events.formatEvent`
840/// inlines the detail as a raw JSON literal, so a single `nan` would not
841/// produce a bad number — it would produce a `dispatchEvent(…)` call the page
842/// cannot parse, taking the whole event with it. CoreMotion should never hand
843/// one back; that is a reason to check cheaply, not a reason to assume.
844fn appendNumber(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), value: f64) !void {
845 if (!std.math.isFinite(value)) return bridge_error.BridgeError.InvalidParameter;
846 var buf: [std.fmt.float.bufferSize(.decimal, f64)]u8 = undefined;
847 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
848}
849
850/// One `{"k":n,"k":n,"k":n}` sub-object. The keys differ between
851/// `acceleration`/`gravity` (x/y/z) and `rotation` (alpha/beta/gamma), so they
852/// are a parameter; the shape does not.
853fn appendVector(
854 allocator: std.mem.Allocator,
855 out: *std.ArrayListUnmanaged(u8),
856 keys: [3][]const u8,
857 values: [3]f64,
858) !void {
859 try out.append(allocator, '{');
860 for (keys, values, 0..) |key, value, i| {
861 if (i > 0) try out.append(allocator, ',');
862 try out.append(allocator, '"');
863 try out.appendSlice(allocator, key);
864 try out.appendSlice(allocator, "\":");
865 try appendNumber(allocator, out, value);
866 }
867 try out.append(allocator, '}');
868}
869
870/// The `detail` of one `craftMotionUpdate`, as the complete JSON value
871/// `ios_events.emit` wants.
872///
873/// Three keys, Swift's three, in Swift's source order. Swift builds a
874/// `Dictionary`, so its own key order is arbitrary and no caller can depend on
875/// it — but a *test* can only pin bytes that are deterministic, so one order is
876/// chosen and held. Nothing here is a string, so nothing needs escaping: every
877/// value is a number this function formats itself.
878///
879/// Caller owns the returned slice.
880pub fn shapeDetail(allocator: std.mem.Allocator, sample: MotionSample) ![]u8 {
881 var out: std.ArrayListUnmanaged(u8) = .empty;
882 errdefer out.deinit(allocator);
883
884 const xyz = [3][]const u8{ "x", "y", "z" };
885
886 try out.appendSlice(allocator, "{\"acceleration\":");
887 try appendVector(allocator, &out, xyz, .{
888 sample.acceleration.x,
889 sample.acceleration.y,
890 sample.acceleration.z,
891 });
892 try out.appendSlice(allocator, ",\"rotation\":");
893 try appendVector(allocator, &out, .{ "alpha", "beta", "gamma" }, .{
894 sample.rotation.alpha,
895 sample.rotation.beta,
896 sample.rotation.gamma,
897 });
898 try out.appendSlice(allocator, ",\"gravity\":");
899 try appendVector(allocator, &out, xyz, .{
900 sample.gravity.x,
901 sample.gravity.y,
902 sample.gravity.z,
903 });
904 try out.append(allocator, '}');
905
906 return out.toOwnedSlice(allocator);
907}
908
909// =============================================================================
910// Tests — host-only.
911//
912// Everything that decides page-visible bytes is pure and pinned here: routing
913// in both directions, the two replies, the three detail keys and their order,
914// the interval's unit and default, non-finite refusal, and a float buffer big
915// enough for 1e300.
916//
917// Nothing here constructs the *module-level* `CMMotionManager`: on the host the
918// Info.plist gate refuses before `ensureManager` is reached, and `manager ==
919// null` is asserted rather than assumed, because "it would refuse" is a claim
920// and not an observation until it is.
921//
922// `objc_getClass("CMMotionManager")` is **not** null on this host, and nothing
923// in this file treats it as though it were — see the availability test below,
924// which allocates a manager of its own precisely so that
925// `isDeviceMotionAvailable` is sent to a real object rather than to nil. The
926// Objective-C paths exercised for real are the ones with no device behind them:
927// selector interning, the block's global flags, the struct-return entry point,
928// the availability refusal, and the handler's no-op paths.
929// =============================================================================
930
931const testing = std.testing;
932
933test "the declared actions are the ones the handler serves" {
934 // Table -> dispatch. A row here is what `craft.capabilities()` shows an
935 // app; declaring an action `handleMessage` answers with UnknownAction
936 // would tell an app craft serves something the shim is actually serving.
937 try testing.expectEqual(@as(usize, 2), capability_actions.len);
938 for (capability_actions) |decl| {
939 try testing.expect(routeFor(decl.name) != null);
940 try testing.expectEqual(capabilities.Reply.result, decl.reply);
941 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
942 // `.live` must not carry a reason; only `.unavailable` may.
943 try testing.expect(decl.reason == null);
944 }
945}
946
947test "every route the dispatcher has is a declared action" {
948 // Dispatch -> table, the direction that catches a served-but-undeclared
949 // action: it works, and `craft.capabilities()` says it does not exist.
950 inline for (std.enums.values(Route)) |route| {
951 const name = switch (route) {
952 .start => A.start_motion_updates,
953 .stop => A.stop_motion_updates,
954 };
955 var found = false;
956 for (capability_actions) |decl| {
957 if (std.mem.eql(u8, decl.name, name)) found = true;
958 }
959 try testing.expect(found);
960 try testing.expectEqual(route, routeFor(name).?);
961 }
962}
963
964test "the action names match the Swift case labels exactly" {
965 // `test/ios_conformance_test.zig` scans the `A` block against
966 // `CraftApp.swift`'s `case "…"` labels, but only as sets — this pins the
967 // spelling at the point a reader is looking at it.
968 try testing.expectEqualStrings("startMotionUpdates", A.start_motion_updates);
969 try testing.expectEqualStrings("stopMotionUpdates", A.stop_motion_updates);
970}
971
972test "an action the namespace does not serve is reported, not ignored" {
973 var bridge = MotionBridge.init(testing.allocator);
974 defer bridge.deinit();
975
976 const E = bridge_error.BridgeError;
977
978 // Casing is how a real typo arrives.
979 try testing.expectError(E.UnknownAction, bridge.handleMessage("startmotionupdates", "{}"));
980 // The plausible near misses, including the shapes other platforms use.
981 try testing.expectError(E.UnknownAction, bridge.handleMessage("startMotion", "{}"));
982 try testing.expectError(E.UnknownAction, bridge.handleMessage("startDeviceMotionUpdates", "{}"));
983 try testing.expectError(E.UnknownAction, bridge.handleMessage("stopMotion", "{}"));
984 // Neighbours that belong to other modules; two modules answering one
985 // action would make `ios_dispatch`'s first-match routing order-dependent.
986 try testing.expectError(E.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}"));
987 try testing.expectError(E.UnknownAction, bridge.handleMessage("getCurrentPosition", "{}"));
988 try testing.expectError(E.UnknownAction, bridge.handleMessage("noSuchAction", "{}"));
989
990 // And `routeFor` agrees, so the two cannot drift apart.
991 try testing.expect(routeFor("startmotionupdates") == null);
992 try testing.expect(routeFor("") == null);
993}
994
995test "both replies are the bare fragment true, not an object wrapper" {
996 // `resolveCallback` serialises with `.fragmentsAllowed` and both Swift
997 // paths pass `result: true` (`CraftApp.swift:3784`, `:865`). A page written
998 // against `await craft.startMotionUpdates()` compares the resolved value,
999 // so `{"ok":true}` would be truthy and wrong.
1000 try testing.expectEqualStrings("true", reply_true);
1001
1002 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, reply_true, .{
1003 .allocate = .alloc_always,
1004 });
1005 defer parsed.deinit();
1006 try testing.expectEqual(true, parsed.value.bool);
1007}
1008
1009test "the event is the iOS spelling, and no capabilities.Channel can carry it" {
1010 // The first half is the contract: `sendToWeb("craftMotionUpdate", …)` is
1011 // what `test-bridges.html:397` listens for.
1012 try testing.expectEqualStrings(event_name, event.eventName());
1013 try testing.expectEqualStrings("craftMotionUpdate", event.eventName());
1014 try testing.expect(std.mem.indexOfScalar(u8, event.eventName(), ':') == null);
1015
1016 // The second half is the reason `ios_events.Event` exists at all, and the
1017 // reason an earlier round of this module served nothing: every
1018 // `capabilities.Channel` name is `craft:`-prefixed, so none of them can
1019 // spell this. Emitting a desktop-spelled name would fire an event with no
1020 // subscriber — a stream that looks implemented and delivers nothing.
1021 for (std.enums.values(capabilities.Channel)) |channel| {
1022 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_name));
1023 try testing.expect(std.mem.startsWith(u8, channel.eventName(), "craft:"));
1024 }
1025 // Non-vacuity: the loop above proves nothing over an empty enum.
1026 try testing.expect(std.enums.values(capabilities.Channel).len >= 40);
1027}
1028
1029// -----------------------------------------------------------------------------
1030// The refusal paths.
1031// -----------------------------------------------------------------------------
1032
1033test "off Darwin both actions refuse rather than pretend" {
1034 if (is_darwin) return error.SkipZigTest;
1035
1036 var bridge = MotionBridge.init(testing.allocator);
1037 defer bridge.deinit();
1038
1039 try testing.expectError(
1040 error.UnsupportedPlatform,
1041 bridge.handleMessage(A.start_motion_updates, "{\"interval\":100}"),
1042 );
1043 try testing.expectError(
1044 error.UnsupportedPlatform,
1045 bridge.handleMessage(A.stop_motion_updates, "{}"),
1046 );
1047}
1048
1049test "without the usage description the gate refuses before any manager exists" {
1050 if (!is_darwin) return error.SkipZigTest;
1051
1052 // A test runner's main bundle has no NSMotionUsageDescription, which is
1053 // exactly the shape of an app built with enableMotionSensors off. The
1054 // refusal must be the config one, and it must happen before CoreMotion is
1055 // touched — `manager` staying null is the observable half of that.
1056 try testing.expect(!try infoPlistHas(key_motion_usage));
1057 try testing.expectError(bridge_error.BridgeError.PermissionDenied, requireMotionConfigured());
1058
1059 var bridge = MotionBridge.init(testing.allocator);
1060 defer bridge.deinit();
1061 try testing.expectError(
1062 bridge_error.BridgeError.PermissionDenied,
1063 bridge.handleMessage(A.start_motion_updates, "{}"),
1064 );
1065 try testing.expect(manager == null);
1066 try testing.expect(!isStreaming());
1067}
1068
1069test "a bad payload is refused before the config gate, and never defaulted" {
1070 if (!is_darwin) return error.SkipZigTest;
1071
1072 var bridge = MotionBridge.init(testing.allocator);
1073 defer bridge.deinit();
1074 const E = bridge_error.BridgeError;
1075
1076 // Ordering matters here: parsing first means a page that sends a mistyped
1077 // interval learns *that*, rather than learning about the Info.plist.
1078 try testing.expectError(E.InvalidJSON, bridge.handleMessage(A.start_motion_updates, "{not json"));
1079 try testing.expectError(
1080 E.InvalidParameter,
1081 bridge.handleMessage(A.start_motion_updates, "{\"interval\":\"fast\"}"),
1082 );
1083 try testing.expect(manager == null);
1084}
1085
1086test "stopMotionUpdates ignores its payload, exactly as Swift reads nothing" {
1087 if (!is_darwin) return error.SkipZigTest;
1088
1089 // Swift's `case "stopMotionUpdates"` touches `body` not at all, so a
1090 // payload that would fail `startMotionUpdates`'s parse must not fail this.
1091 // This runs a handler all the way to `sendResultToJS`, which logs one
1092 // "failed to send bridge result to JS" warning: there is no webview in a
1093 // test runner. That warning is the evidence the reply was attempted — the
1094 // alternative, asserting only that no error came back, would also pass for
1095 // a handler that returned without replying at all.
1096 var bridge = MotionBridge.init(testing.allocator);
1097 defer bridge.deinit();
1098
1099 try bridge.handleMessage(A.stop_motion_updates, "{not json");
1100 try bridge.handleMessage(A.stop_motion_updates, "{\"interval\":\"fast\"}");
1101 // And a stop with nothing running still answers: the postcondition the
1102 // page asked for holds, which is why `true` here is not a fabrication.
1103 try bridge.handleMessage(A.stop_motion_updates, "{}");
1104 try testing.expect(!isStreaming());
1105}
1106
1107// -----------------------------------------------------------------------------
1108// The Objective-C surface a host can honestly exercise.
1109// -----------------------------------------------------------------------------
1110
1111test "every selector the dispatcher and the handler need resolves on a real runtime" {
1112 if (!is_darwin) return error.SkipZigTest;
1113
1114 // `sel_registerName` interns a name whether or not any class implements
1115 // it, so this proves the *spelling* is registrable — a typo would be a
1116 // selector that resolves and then does nothing on a device, which is the
1117 // failure this cannot catch and the reason every name is written out
1118 // against `CraftApp.swift` above.
1119 const sels = try Sels.resolve();
1120 try testing.expect(sels.user_acceleration != null);
1121 try testing.expect(sels.gravity != null);
1122 try testing.expect(sels.attitude != null);
1123 try testing.expect(sels.roll != null);
1124 try testing.expect(sels.pitch != null);
1125 try testing.expect(sels.yaw != null);
1126
1127 for ([_][*:0]const u8{
1128 "isDeviceMotionAvailable",
1129 "setDeviceMotionUpdateInterval:",
1130 "startDeviceMotionUpdatesToQueue:withHandler:",
1131 "stopDeviceMotionUpdates",
1132 "mainQueue",
1133 }) |name| {
1134 try testing.expect((try selector(name)) != null);
1135 }
1136
1137 // NSOperationQueue is Foundation, not CoreMotion, so it really is present
1138 // on the host — the queue path is the one piece of the start sequence a
1139 // host can run for real.
1140 try testing.expect((try mainOperationQueue()) != @as(?*anyopaque, null));
1141}
1142
1143test "the availability guard really answers NO on a machine with no device motion" {
1144 if (!is_darwin) return error.SkipZigTest;
1145
1146 // A correction, measured rather than assumed: `CMMotionManager.h` is
1147 // `API_UNAVAILABLE(macos)`, and an earlier note in this module concluded
1148 // from that that `objc_getClass("CMMotionManager")` is null on macOS. It
1149 // is **not** — CoreMotion ships on macOS and the class is present on this
1150 // runner. So "the host cannot reach CoreMotion" is not a guard, and
1151 // nothing in this file uses it as one.
1152 //
1153 // What *is* the guard is `isDeviceMotionAvailable`, and this exercises it
1154 // for real: a Mac has no device-motion service, exactly as a simulator has
1155 // none, and NO is the answer that must stop `startMotionUpdates` before it
1156 // streams zeroes nothing measured.
1157 const cls = objc.objc_getClass("CMMotionManager") orelse return error.SkipZigTest;
1158
1159 const sel_available = try selector("isDeviceMotionAvailable");
1160 const sel_responds = objc.sel_registerName("respondsToSelector:") orelse
1161 return error.SelectorNotFound;
1162 const RespondsFn = *const fn (Id, Id, Id) callconv(.c) bool;
1163 const responds: RespondsFn = @ptrCast(&objc.objc_msgSend);
1164
1165 // This test's own manager, never the module-level one: caching a manager
1166 // in `manager` here would change what every later test sees.
1167 const own = (try objc.allocInit(cls)) orelse return error.NativeCallFailed;
1168 defer objc.release(own);
1169
1170 // Never send a selector the class does not implement — that is an
1171 // unrecognised-selector abort, not a test failure.
1172 if (!responds(own, sel_responds, sel_available)) return error.SkipZigTest;
1173 try testing.expect(!objc.msgSendBool(own, sel_available));
1174
1175 // And the module's own manager is still unbuilt, because nothing in this
1176 // file has been allowed to reach `ensureManager`.
1177 try testing.expect(manager == null);
1178 try testing.expect(!isStreaming());
1179}
1180
1181test "the handler block is global, so Block_copy cannot heap-copy it" {
1182 if (!is_darwin) return error.SkipZigTest;
1183
1184 // `startDeviceMotionUpdatesToQueue:withHandler:` escapes the block and
1185 // holds it for the life of the stream. A stack block would be a dangling
1186 // call; a heap copy would need copy/dispose helpers this descriptor does
1187 // not have. BLOCK_IS_GLOBAL is what makes `Block_copy` the identity.
1188 try testing.expectEqual(&_NSConcreteGlobalBlock, motion_block.isa.?);
1189 try testing.expect(motion_block.flags & BLOCK_IS_GLOBAL != 0);
1190 try testing.expectEqual(@as(c_int, 0), motion_block.reserved);
1191 try testing.expectEqual(@as(c_ulong, @sizeOf(MotionBlock)), motion_block.descriptor.size);
1192 try testing.expectEqual(@as(c_ulong, 0), motion_block.descriptor.reserved);
1193}
1194
1195test "a 24-byte CMAcceleration goes through the entry point its ABI requires" {
1196 // The trap `objc_runtime.msgSendStret` walks into: it picks
1197 // `objc_msgSend_stret` for anything over 16 bytes, and arm64 libobjc does
1198 // not export that symbol at all.
1199 try testing.expectEqual(@as(usize, 24), @sizeOf(CMAcceleration));
1200 try testing.expectEqual(@as(usize, 16), @offsetOf(CMAcceleration, "z"));
1201
1202 if (!is_darwin) return error.SkipZigTest;
1203 if (comptime builtin.target.cpu.arch == .x86_64) {
1204 try testing.expectEqual(@as(MsgSendEntry, &objc.objc_msgSend_stret), acceleration_entry);
1205 } else {
1206 try testing.expectEqual(@as(MsgSendEntry, &objc.objc_msgSend), acceleration_entry);
1207 // Deliberately no "and it is not objc_msgSend_stret" assertion here:
1208 // writing that would *itself* reference the symbol arm64 libobjc does
1209 // not export, and this test would be the undefined symbol it exists to
1210 // prevent. The `expectEqual` above already pins the choice.
1211 }
1212}
1213
1214// -----------------------------------------------------------------------------
1215// The stream's state, and the handler's three silent exits.
1216// -----------------------------------------------------------------------------
1217
1218test "arming and disarming decide whether a callback is live" {
1219 if (!is_darwin) return error.SkipZigTest;
1220 defer disarmStream();
1221
1222 try testing.expect(!isStreaming());
1223 try testing.expect(streamSels() == null);
1224
1225 armStream(try Sels.resolve());
1226 try testing.expect(isStreaming());
1227 try testing.expect(streamSels() != null);
1228
1229 disarmStream();
1230 try testing.expect(!isStreaming());
1231 try testing.expect(streamSels() == null);
1232}
1233
1234test "the fault log fires once per start, so a broken stream cannot flood" {
1235 if (!is_darwin) return error.SkipZigTest;
1236 defer disarmStream();
1237
1238 armStream(try Sels.resolve());
1239 try testing.expect(claimFaultLog());
1240 try testing.expect(!claimFaultLog());
1241 try testing.expect(!claimFaultLog());
1242
1243 // A restart is a new stream and gets its own line.
1244 armStream(try Sels.resolve());
1245 try testing.expect(claimFaultLog());
1246}
1247
1248test "a callback with no stream armed does nothing at all" {
1249 if (!is_darwin) return error.SkipZigTest;
1250
1251 // The shape of a block draining off the main queue after
1252 // `stopMotionUpdates`. It must not emit, and it must not log — a queued
1253 // block after a stop is expected, not a fault.
1254 disarmStream();
1255 motionHandler(@ptrCast(&motion_block), null, null);
1256
1257 // Nothing consumed the fault budget, because the handler returned before
1258 // reaching it.
1259 armStream(try Sels.resolve());
1260 defer disarmStream();
1261 try testing.expect(claimFaultLog());
1262}
1263
1264test "a callback with no sample emits nothing and spends the fault budget" {
1265 if (!is_darwin) return error.SkipZigTest;
1266 defer disarmStream();
1267
1268 // Swift's `guard let motion = motion else { return }`. The point of the
1269 // test is rule 1: there is no `craftMotionError` in the iOS vocabulary, so
1270 // a nil sample must produce *no* event rather than a plausible one full of
1271 // zeroes. `claimFaultLog` returning false afterwards is the evidence the
1272 // handler took the no-sample exit and not the emit path — the emit path
1273 // never touches the fault budget.
1274 armStream(try Sels.resolve());
1275 motionHandler(@ptrCast(&motion_block), null, null);
1276 try testing.expect(!claimFaultLog());
1277}
1278
1279// -----------------------------------------------------------------------------
1280// The payload contract.
1281// -----------------------------------------------------------------------------
1282
1283fn intervalOf(json: []const u8) !f64 {
1284 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1285 defer parsed.deinit();
1286 return intervalSeconds(parsed.value);
1287}
1288
1289test "interval is milliseconds and arrives as seconds" {
1290 // `CraftApp.swift:3758`: `let updateInterval = interval / 1000.0`. Getting
1291 // the unit wrong by 1000 is a sensor that either never fires or melts the
1292 // battery, and neither looks like a bug from the page.
1293 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{\"interval\":100}"));
1294 try testing.expectEqual(@as(f64, 0.02), try intervalOf("{\"interval\":20}"));
1295 try testing.expectEqual(@as(f64, 1.0), try intervalOf("{\"interval\":1000}"));
1296}
1297
1298test "an absent interval defaults to Swift's 100ms, not to zero" {
1299 // The one place a default is the *right* answer: `?? 100` is the shipped
1300 // contract on iOS and Android alike. Defaulting to 0 would ask CoreMotion
1301 // for the fastest rate the hardware has.
1302 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{}"));
1303 // A caller that passes unrelated fields must not be refused, since Swift
1304 // reads only this one key.
1305 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{\"callbackId\":\"cb_7\"}"));
1306}
1307
1308test "an integer and a float interval mean the same thing" {
1309 // `std.json` produces `.integer` for a bare `100` and `.float` for `100.0`,
1310 // and a page writing either means the same thing. Handling only one arm is
1311 // the classic way to drop a field the page really did send.
1312 try testing.expectEqual(try intervalOf("{\"interval\":100}"), try intervalOf("{\"interval\":100.0}"));
1313 try testing.expectEqual(@as(f64, 0.0165), try intervalOf("{\"interval\":16.5}"));
1314 // Negative and zero pass through, as Swift passes them through: the header
1315 // says the property is capped to hardware minimum and maximum, so refusing
1316 // them here would invent a rejection the shim does not have.
1317 try testing.expectEqual(@as(f64, 0.0), try intervalOf("{\"interval\":0}"));
1318 try testing.expectEqual(@as(f64, -0.005), try intervalOf("{\"interval\":-5}"));
1319}
1320
1321test "the field name the page sends is the one that is read" {
1322 // `body["interval"]` in the Swift dispatcher; `startMotionUpdates(interval
1323 // || 100)` in Android's injected JS. The plausible wrong names must read as
1324 // *absent*, or a handler bound to the wrong key would still pass the tests
1325 // above by falling into the default.
1326 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{\"updateInterval\":20}"));
1327 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{\"frequency\":20}"));
1328 try testing.expectEqual(@as(f64, 0.1), try intervalOf("{\"intervalMs\":20}"));
1329}
1330
1331test "a non-numeric interval is refused rather than silently defaulted" {
1332 // Swift's `as? Double` falls back to 100 for a string and — because
1333 // Foundation bridges `__NSCFBoolean` to `NSNumber` — turns `true` into a
1334 // 1 ms interval. Both are a mistyped field acted on as if it were valid.
1335 const E = bridge_error.BridgeError;
1336 try testing.expectError(E.InvalidParameter, intervalOf("{\"interval\":\"100\"}"));
1337 try testing.expectError(E.InvalidParameter, intervalOf("{\"interval\":true}"));
1338 try testing.expectError(E.InvalidParameter, intervalOf("{\"interval\":null}"));
1339 try testing.expectError(E.InvalidParameter, intervalOf("{\"interval\":[100]}"));
1340 try testing.expectError(E.InvalidParameter, intervalOf("{\"interval\":{\"ms\":100}}"));
1341}
1342
1343test "an out-of-range interval literal is read, and refused only if it is not finite" {
1344 // `std.json` parks both of these in `.number_string`: one is an integer too
1345 // big for `i64`, the other parses non-finite. They are different answers.
1346 // 1e20 ms is an absurd interval and still a number the page sent, so it is
1347 // carried; `1e400` is infinity, which is undefined behaviour inside
1348 // CoreMotion rather than a slow update.
1349 try testing.expectEqual(@as(f64, 1e17), try intervalOf("{\"interval\":100000000000000000000}"));
1350 try testing.expectError(bridge_error.BridgeError.InvalidParameter, intervalOf("{\"interval\":1e400}"));
1351 try testing.expectError(bridge_error.BridgeError.InvalidParameter, intervalOf("{\"interval\":-1e400}"));
1352}
1353
1354test "a payload that is not an object is bad JSON, not a missing field" {
1355 const E = bridge_error.BridgeError;
1356 var array = try std.json.parseFromSlice(std.json.Value, testing.allocator, "[100]", .{});
1357 defer array.deinit();
1358 try testing.expectError(E.InvalidJSON, intervalSeconds(array.value));
1359
1360 var scalar = try std.json.parseFromSlice(std.json.Value, testing.allocator, "100", .{});
1361 defer scalar.deinit();
1362 try testing.expectError(E.InvalidJSON, intervalSeconds(scalar.value));
1363}
1364
1365// -----------------------------------------------------------------------------
1366// The event detail contract.
1367// -----------------------------------------------------------------------------
1368
1369/// One number out of a parsed detail, whichever way `std.json` chose to carry
1370/// it.
1371///
1372/// `{d}` renders decimal notation, so `1e300` comes back as 301 digits with no
1373/// decimal point and no exponent — which `std.json` reads as an integer
1374/// literal, fails to fit in `i64`, and parks in `.number_string`. Reading only
1375/// `.float` would make the extreme-value test panic on exactly the values it
1376/// exists to check.
1377fn numberOf(value: std.json.Value) !f64 {
1378 return switch (value) {
1379 .float => |f| f,
1380 .integer => |i| @floatFromInt(i),
1381 .number_string => |s| try std.fmt.parseFloat(f64, s),
1382 else => error.NotANumber,
1383 };
1384}
1385
1386const sample_reading = MotionSample{
1387 .acceleration = .{ .x = 0.01, .y = -0.5, .z = 0.25 },
1388 .rotation = .{ .alpha = 1.5, .beta = -0.25, .gamma = 0.125 },
1389 .gravity = .{ .x = 0.0, .y = -1.0, .z = 0.5 },
1390};
1391
1392test "the detail is Swift's three keys, with Swift's sub-keys, as exact bytes" {
1393 // `CraftApp.swift:3764-3780`, verbatim. `sendToWeb` inlines this as the
1394 // `detail` literal, exactly as `ios_events.formatEvent` does, so these are
1395 // the bytes that reach the page's `e.detail` either way.
1396 const json = try shapeDetail(testing.allocator, sample_reading);
1397 defer testing.allocator.free(json);
1398
1399 try testing.expectEqualStrings(
1400 "{\"acceleration\":{\"x\":0.01,\"y\":-0.5,\"z\":0.25}," ++
1401 "\"rotation\":{\"alpha\":1.5,\"beta\":-0.25,\"gamma\":0.125}," ++
1402 "\"gravity\":{\"x\":0,\"y\":-1,\"z\":0.5}}",
1403 json,
1404 );
1405
1406 // And it parses as the nested object the page indexes into, rather than as
1407 // a string that happens to contain one.
1408 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1409 defer parsed.deinit();
1410 const detail = parsed.value.object;
1411 try testing.expectEqual(@as(usize, 3), detail.count());
1412 try testing.expectEqual(@as(f64, -0.5), detail.get("acceleration").?.object.get("y").?.float);
1413 try testing.expectEqual(@as(f64, 1.5), detail.get("rotation").?.object.get("alpha").?.float);
1414 try testing.expectEqual(@as(usize, 3), detail.get("gravity").?.object.count());
1415}
1416
1417test "the detail survives being inlined into the dispatchEvent call" {
1418 // The other half of the contract: `ios_events.formatEvent` splices these
1419 // bytes in as a JSON *literal*, not as a quoted string. Shaping the detail
1420 // correctly and then having it arrive as a string would leave every
1421 // `e.detail.acceleration` undefined, which is indistinguishable from the
1422 // sensor never firing.
1423 const json = try shapeDetail(testing.allocator, sample_reading);
1424 defer testing.allocator.free(json);
1425
1426 // Nothing in a detail of pure numbers can need escaping, and a stray quote
1427 // would end the CustomEvent argument early.
1428 try testing.expect(std.mem.indexOfScalar(u8, json, '\'') == null);
1429 try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null);
1430 try testing.expect(std.mem.startsWith(u8, json, "{"));
1431 try testing.expect(std.mem.endsWith(u8, json, "}"));
1432}
1433
1434test "the detail does not invent the accelerometer and gyroscope keys the docs read" {
1435 // The rule-4 regression test. `test-bridges.html:398-400`, `README.md` and
1436 // `craft.d.ts`'s `CraftMotionUpdateEvent` all read
1437 // `e.detail.accelerometer` / `e.detail.gyroscope`, and no platform has ever
1438 // emitted either. Matching the stale docs would be a rename Swift never
1439 // performed — and `gyroscope` would be an outright lie, because `rotation`
1440 // carries `CMAttitude` in radians, not `CMDeviceMotion.rotationRate`.
1441 //
1442 // The docs are the bug. They are reported separately, not fixed by
1443 // corrupting the emitter.
1444 const json = try shapeDetail(testing.allocator, sample_reading);
1445 defer testing.allocator.free(json);
1446
1447 try testing.expect(std.mem.indexOf(u8, json, "accelerometer") == null);
1448 try testing.expect(std.mem.indexOf(u8, json, "gyroscope") == null);
1449 try testing.expect(std.mem.indexOf(u8, json, "rotationRate") == null);
1450 // And the three keys that must be there, spelled as objects.
1451 try testing.expect(std.mem.indexOf(u8, json, "\"acceleration\":{") != null);
1452 try testing.expect(std.mem.indexOf(u8, json, "\"rotation\":{") != null);
1453 try testing.expect(std.mem.indexOf(u8, json, "\"gravity\":{") != null);
1454}
1455
1456test "extreme doubles are rendered in full instead of overflowing the buffer" {
1457 // The `[64]u8` trap. `{d}` renders decimal notation, never scientific: a
1458 // finite f64 can need up to `bufferSize(.decimal, f64)` = 347 bytes, and
1459 // the real worst case is the smallest subnormal at 326 characters. A
1460 // smaller buffer turns a legal reading into `NoSpaceLeft` — an event that
1461 // silently stops arriving, on a device, with nothing to point at.
1462 //
1463 // CoreMotion will not produce these; the point is that the buffer is sized
1464 // by the type's range rather than by what a sensor is expected to do.
1465 const extreme = MotionSample{
1466 .acceleration = .{ .x = std.math.floatMax(f64), .y = -std.math.floatMax(f64), .z = 1e300 },
1467 .rotation = .{ .alpha = std.math.floatMin(f64), .beta = -1e-300, .gamma = 5e-324 },
1468 .gravity = .{ .x = -0.0, .y = 1e-308, .z = -1e308 },
1469 };
1470
1471 const json = try shapeDetail(testing.allocator, extreme);
1472 defer testing.allocator.free(json);
1473
1474 // Long enough that a 64-byte-per-number implementation could not have
1475 // produced it, and still valid JSON.
1476 try testing.expect(json.len > 1500);
1477 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1478 defer parsed.deinit();
1479 try testing.expectEqual(@as(usize, 3), parsed.value.object.count());
1480
1481 // The values survive the round trip rather than being truncated into a
1482 // different reading.
1483 const accel = parsed.value.object.get("acceleration").?.object;
1484 try testing.expectEqual(std.math.floatMax(f64), try numberOf(accel.get("x").?));
1485 try testing.expectEqual(-std.math.floatMax(f64), try numberOf(accel.get("y").?));
1486 try testing.expectEqual(@as(f64, 1e300), try numberOf(accel.get("z").?));
1487 const rot = parsed.value.object.get("rotation").?.object;
1488 try testing.expectEqual(std.math.floatMin(f64), try numberOf(rot.get("alpha").?));
1489 try testing.expectEqual(@as(f64, 5e-324), try numberOf(rot.get("gamma").?));
1490 const grav = parsed.value.object.get("gravity").?.object;
1491 try testing.expectEqual(@as(f64, -1e308), try numberOf(grav.get("z").?));
1492}
1493
1494test "the float buffer is sized from the formatter, not from a guess" {
1495 // The number the buffer must be, stated independently of the code that
1496 // uses it, so shrinking it is a test failure rather than a device bug.
1497 try testing.expectEqual(@as(usize, 347), std.fmt.float.bufferSize(.decimal, f64));
1498}
1499
1500test "an ordinary reading stays short, so the buffer is a ceiling and not a cost" {
1501 // Sanity in the other direction: sizing for the worst case must not pad
1502 // every sample. At 100 Hz this string is built a hundred times a second.
1503 const json = try shapeDetail(testing.allocator, sample_reading);
1504 defer testing.allocator.free(json);
1505 try testing.expect(json.len < 140);
1506}
1507
1508test "a non-finite reading is refused, because it would break the whole event" {
1509 // `ios_events.formatEvent` inlines the detail as a raw JSON literal, so a
1510 // `nan` or an `inf` does not produce a bad number — it produces a
1511 // `dispatchEvent(…)` call the page cannot parse, and the event is lost
1512 // rather than degraded. Refusing lets the handler drop one sample instead.
1513 const E = bridge_error.BridgeError;
1514
1515 var nan_sample = sample_reading;
1516 nan_sample.rotation.beta = std.math.nan(f64);
1517 try testing.expectError(E.InvalidParameter, shapeDetail(testing.allocator, nan_sample));
1518
1519 var inf_sample = sample_reading;
1520 inf_sample.acceleration.z = std.math.inf(f64);
1521 try testing.expectError(E.InvalidParameter, shapeDetail(testing.allocator, inf_sample));
1522
1523 // Including in the last field written, so a check that only guarded the
1524 // first vector would not pass.
1525 var tail_sample = sample_reading;
1526 tail_sample.gravity.z = -std.math.inf(f64);
1527 try testing.expectError(E.InvalidParameter, shapeDetail(testing.allocator, tail_sample));
1528}
1529
1530test "zero and negative zero are JSON numbers, not omitted fields" {
1531 // A device lying flat reports zeros on two axes. `{d}` renders them `0` and
1532 // `-0`, both of which are legal JSON numbers that parse back to zero — the
1533 // failure mode worth ruling out is a shaper that skips a zero field.
1534 const flat = MotionSample{
1535 .acceleration = .{ .x = 0.0, .y = 0.0, .z = 0.0 },
1536 .rotation = .{ .alpha = 0.0, .beta = -0.0, .gamma = 0.0 },
1537 .gravity = .{ .x = 0.0, .y = 0.0, .z = -1.0 },
1538 };
1539
1540 const json = try shapeDetail(testing.allocator, flat);
1541 defer testing.allocator.free(json);
1542
1543 try testing.expectEqualStrings(
1544 "{\"acceleration\":{\"x\":0,\"y\":0,\"z\":0}," ++
1545 "\"rotation\":{\"alpha\":0,\"beta\":-0,\"gamma\":0}," ++
1546 "\"gravity\":{\"x\":0,\"y\":0,\"z\":-1}}",
1547 json,
1548 );
1549
1550 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1551 defer parsed.deinit();
1552 try testing.expectEqual(@as(usize, 3), parsed.value.object.get("rotation").?.object.count());
1553}
packages/zig/src/ios_dispatch.zigmodified+8-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -4,6 +4,7 @@ const objc_runtime = ;
44const request_context = ;
55const bridge_error = ;
66pub const ios_async = ;
7pub const ios_events = ;
78const bridge_mobile = ;
89const bridge_mobile_clipboard = ;
910const bridge_mobile_haptics = ;
@@ -21,6 +22,9 @@ const bridge_mobile_notifcancel = ;
2122const bridge_mobile_notifications = ;
2223const bridge_mobile_bgtasks = ;
2324const bridge_mobile_watch = ;
25const bridge_mobile_location = ;
26const bridge_mobile_locrecording = ;
27const bridge_mobile_motion = ;
2428
2529const objc = objc_runtime.objc;
2630
@@ -225,6 +229,9 @@ const mobile_bridges = .{
225229 bridge_mobile_notifications.NotificationsBridge,
226230 bridge_mobile_bgtasks.BgTasksBridge,
227231 bridge_mobile_watch.WatchBridge,
232 bridge_mobile_location.LocationBridge,
233 bridge_mobile_locrecording.LocationRecordingBridge,
234 bridge_mobile_motion.MotionBridge,
228235};
229236
230237/// Narrow an arbitrary handler error to one the page's error codes can express.
@@ -380,6 +387,7 @@ fn buildShimError(
380387
381388test {
382389 _ = ios_async;
390 _ = ios_events;
383391}
384392
385393const testing = std.testing;
packages/zig/src/ios_events.zigadded+301-0
Changes to packages/zig/src/ios_events.zig
@@ -0,0 +1,301 @@
1//! Pushing an event to the page, from wherever the event happened.
2//!
3//! A reply answers a call the page made; an event is native talking first.
4//! That difference decides everything about this file. There is no request id
5//! to carry, so `ios_async`'s slot pool has nothing to hold — but the hard part
6//! it solves is still here: a `CLLocationManager` delegate callback, a
7//! `CMMotionManager` handler, a `SFSpeechRecognizer` result all arrive on
8//! whatever queue the framework chose, and `evaluateJavaScript` is
9//! main-thread-only.
10//!
11//! The desktop emits events by concatenating JavaScript by hand —
12//! `bridge_iap.zig:362` builds `dispatchEvent(new CustomEvent('...', { detail:
13//! { productId: '` and then appends a value with its own escaping. That works
14//! until a value contains a quote. Here the detail is already JSON, so it is
15//! inlined as an object literal and never escaped twice; the only thing
16//! interpolated as a *name* comes from the `Channel` enum, which is a fixed
17//! set of literals rather than anything a page or a device can influence.
18//!
19//! ## The event vocabulary is iOS's, not the desktop's
20//!
21//! This file was first written against `capabilities.Channel`, whose names are
22//! all `craft:`-prefixed (`craft:location:update`) and whose own test enforces
23//! that prefix. The iOS page contract is a different vocabulary: Swift's
24//! `sendToWeb` dispatches `craftLocationUpdate`, `craftMotionUpdate`,
25//! `craftSpeechResult` — camelCase, no colon. A `Channel` therefore cannot
26//! *spell* the name an iOS page is listening for, and emitting the desktop
27//! spelling would fire an event with no subscriber: a stream that looks
28//! implemented and delivers nothing.
29//!
30//! So `Event` below is the iOS vocabulary, taken from the `sendToWeb` call
31//! sites. Where a desktop channel means the same thing, `capabilityChannel`
32//! maps to it, and emitting marks that channel live — so `craft.capabilities()`
33//! still answers from the code that does the emitting rather than a
34//! hand-maintained list. Where there is no desktop equivalent the mapping is
35//! null, which is honest rather than approximate.
36
37const std = ;
38const builtin = ;
39const capabilities = ;
40const compat_mutex = ;
41
42/// The events an iOS page subscribes to.
43///
44/// Every name here is copied from a `sendToWeb` call in
45/// `packages/ios/templates/CraftApp.swift` — that is the contract, and a page
46/// written against the Swift app must keep working when Zig serves the same
47/// action. `eventName` is what lands in the `CustomEvent` constructor.
48pub const Event = enum {
49 location_update,
50 location_error,
51 motion_update,
52 speech_result,
53 speech_error,
54 speech_end,
55 network_change,
56 deep_link,
57 push_token,
58 notification_response,
59 bluetooth_device,
60 ar_plane,
61
62 pub fn eventName(self: Event) []const u8 {
63 return switch (self) {
64 .location_update => "craftLocationUpdate",
65 .location_error => "craftLocationError",
66 .motion_update => "craftMotionUpdate",
67 .speech_result => "craftSpeechResult",
68 .speech_error => "craftSpeechError",
69 .speech_end => "craftSpeechEnd",
70 .network_change => "craftNetworkChange",
71 .deep_link => "craftDeepLink",
72 .push_token => "craftPushToken",
73 .notification_response => "craftNotificationResponse",
74 .bluetooth_device => "craftBluetoothDevice",
75 .ar_plane => "craftARPlane",
76 };
77 }
78};
79
80// There is deliberately no mapping from an `Event` to a
81// `capabilities.Channel`.
82//
83// An earlier version had one, and emitting `craftLocationUpdate` registered
84// `craft:location:update` as live. That reads as a small convenience and is a
85// false claim: `Liveness.live` means "something in this build took out a
86// permit to emit on it", and nothing in an iOS build ever dispatches the
87// `craft:`-prefixed name. A page reading `craft.capabilities()` would be told
88// a channel is live that it cannot receive — the overclaiming this manifest
89// exists to prevent, committed by the manifest's own plumbing.
90//
91// `unknown` is the honest answer for a desktop channel on iOS. It also removed
92// an unsynchronised write: `registerEmitter` sets `live_channels[...]` and
93// `emit` is explicitly callable from any thread.
94//
95// When the capabilities vocabulary grows names an iOS page actually
96// subscribes to, this becomes a real mapping rather than an approximate one.
97
98/// A queued event, owned until the main queue drains it.
99const Pending = struct {
100 in_use: bool = false,
101 /// The channel, not its name: an enum is a fixed vocabulary, and the
102 /// formatter reads the literal from it rather than accepting a string that
103 /// could carry a quote into a JS source position.
104 event: Event = .location_update,
105 /// The `detail` payload as JSON. Owned; freed after delivery.
106 detail: ?[]u8 = null,
107};
108
109/// Events can burst — a location subscription at high accuracy fires several
110/// times a second, and a page that has stopped listening still costs a hop.
111/// Sixteen deep matches the async pool; beyond that the oldest queued event is
112/// dropped in favour of the newest, which is the right trade for a stream
113/// whose consumer wants current state rather than history.
114const max_queued = 16;
115
116var queue: [max_queued]Pending = @splat(.{});
117var queue_mutex: compat_mutex.Mutex = .{};
118
119/// How many events were dropped because the queue was full.
120///
121/// Counted rather than logged per-drop: a saturated stream would otherwise
122/// produce a log line per event and drown the thing being diagnosed. Read it
123/// when a page reports missing updates.
124var dropped: usize = 0;
125
126pub fn droppedCount() usize {
127 queue_mutex.lock();
128 defer queue_mutex.unlock();
129 return dropped;
130}
131
132const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
133extern "c" fn dispatch_async_f(queue_ref: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
134extern var _dispatch_main_q: anyopaque;
135
136/// Emit `channel` with `detail_json` as the event's `detail`.
137///
138/// Safe to call from any thread. `detail_json` must be a complete JSON value —
139/// an object literal is the convention — and is copied, because the caller is
140/// typically a delegate callback whose buffer dies on return.
141///
142/// A full queue drops the *oldest* pending event rather than refusing the new
143/// one. For a stream, stale data is worse than a gap.
144pub fn emit(event: Event, detail_json: []const u8) void {
145 if (!builtin.target.os.tag.isDarwin()) return;
146
147 const allocator = std.heap.c_allocator;
148 const copy = allocator.dupe(u8, detail_json) catch {
149 // Nothing to deliver and nobody to tell — an event has no promise
150 // waiting on it. Counted so a page reporting gaps has something to
151 // point at.
152 queue_mutex.lock();
153 defer queue_mutex.unlock();
154 dropped += 1;
155 return;
156 };
157
158 var index: usize = undefined;
159 {
160 queue_mutex.lock();
161 defer queue_mutex.unlock();
162
163 index = findFreeSlot() orelse blk: {
164 // Evict the oldest. Slot 0 is as good as any: the queue is drained
165 // in index order, so it holds the least recently delivered.
166 if (queue[0].detail) |old| allocator.free(old);
167 queue[0] = .{};
168 dropped += 1;
169 break :blk 0;
170 };
171
172 queue[index] = .{ .in_use = true, .event = event, .detail = copy };
173 }
174
175 dispatch_async_f(&_dispatch_main_q, @ptrFromInt(index + 1), deliverOnMain);
176}
177
178fn findFreeSlot() ?usize {
179 for (&queue, 0..) |*slot, i| {
180 if (!slot.in_use) return i;
181 }
182 return null;
183}
184
185fn deliverOnMain(context: ?*anyopaque) callconv(.c) void {
186 const index: usize = @intFromPtr(context orelse return) - 1;
187 if (index >= max_queued) return;
188
189 var event: Event = undefined;
190 var detail: ?[]u8 = null;
191 {
192 queue_mutex.lock();
193 defer queue_mutex.unlock();
194 const slot = &queue[index];
195 if (!slot.in_use) return; // evicted before this hop ran
196 event = slot.event;
197 detail = slot.detail;
198 slot.* = .{};
199 }
200
201 const allocator = std.heap.c_allocator;
202 const payload = detail orelse return;
203 defer allocator.free(payload);
204
205 const js = formatEvent(allocator, event, payload) catch return;
206 defer allocator.free(js);
207
208 const ios_dispatch = ;
209 ios_dispatch.evalJS(js) catch |err| {
210 std.log.warn("ios events: could not emit {s}: {}", .{ event.eventName(), err });
211 };
212}
213
214/// The `dispatchEvent` call for one event.
215///
216/// The detail is inlined as a JSON literal, not as a quoted string: it is
217/// already JSON, and wrapping it would make the page parse a string containing
218/// an object. The `if (window.dispatchEvent)` guard mirrors the desktop's, for
219/// a document torn down between the emit and the hop.
220///
221/// Split out from `deliverOnMain` so the exact bytes are testable without a
222/// webview or a run loop.
223fn formatEvent(
224 allocator: std.mem.Allocator,
225 event: Event,
226 detail_json: []const u8,
227) ![]u8 {
228 return std.fmt.allocPrint(
229 allocator,
230 "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('{s}',));",
231 .{ event.eventName(), detail_json },
232 );
233}
234
235const testing = std.testing;
236
237test "an event inlines its detail as JSON, not as a quoted string" {
238 // Quoting it would hand the page a string that happens to contain an
239 // object, so `e.detail.latitude` would be undefined and the subscriber
240 // would read it as a missing reading rather than a bug.
241 const js = try formatEvent(testing.allocator, .location_update, "{\"latitude\":1.5}");
242 defer testing.allocator.free(js);
243
244 try testing.expectEqualStrings(
245 "if(window.dispatchEvent)window.dispatchEvent(new CustomEvent('craftLocationUpdate',{detail:{\"latitude\":1.5}}));",
246 js,
247 );
248}
249
250test "every event name is one an iOS page actually subscribes to" {
251 // The names come from Swift's sendToWeb call sites, NOT from
252 // capabilities.Channel — whose names are all `craft:`-prefixed and which
253 // therefore cannot spell any of these. Emitting the desktop spelling would
254 // fire an event with no listener: a stream that looks implemented and
255 // delivers nothing. This is the check that keeps the two vocabularies from
256 // being confused again.
257 try testing.expectEqualStrings("craftLocationUpdate", Event.location_update.eventName());
258 try testing.expectEqualStrings("craftMotionUpdate", Event.motion_update.eventName());
259 try testing.expectEqualStrings("craftSpeechResult", Event.speech_result.eventName());
260
261 inline for (std.enums.values(Event)) |e| {
262 // camelCase, no colon — the iOS convention.
263 try testing.expect(std.mem.startsWith(u8, e.eventName(), "craft"));
264 try testing.expect(std.mem.indexOfScalar(u8, e.eventName(), ':') == null);
265 }
266}
267
268test "emitting claims nothing about the desktop channel vocabulary" {
269 // The iOS names and the `craft:` names are different vocabularies. An
270 // earlier version registered a desktop channel on emit, which made
271 // craft.capabilities() report `craft:location:update` live on a platform
272 // that never dispatches it. A manifest whose own plumbing overclaims is
273 // worse than no manifest, so the mapping is gone — and this fails if it
274 // comes back.
275 try testing.expect(!@hasDecl(Event, "capabilityChannel"));
276}
277
278test "a full queue drops the oldest event and counts it" {
279 // A stream's consumer wants current state. Refusing the newest reading to
280 // preserve a stale one is the wrong trade, but a silent drop is worse than
281 // a counted one.
282 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
283
284 const before = droppedCount();
285
286 queue_mutex.lock();
287 for (&queue) |*slot| {
288 if (!slot.in_use) slot.* = .{ .in_use = true, .event = .location_update, .detail = null };
289 }
290 queue_mutex.unlock();
291
292 emit(.location_update, "{\"n\":1}");
293 try testing.expect(droppedCount() > before);
294
295 queue_mutex.lock();
296 defer queue_mutex.unlock();
297 for (&queue) |*slot| {
298 if (slot.detail) |owned| std.heap.c_allocator.free(owned);
299 slot.* = .{};
300 }
301}
packages/zig/test/ios_conformance_test.zigmodified+6-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -41,6 +41,9 @@ const zig_sources = [_][]const u8{
4141 @embedFile("src/bridge_mobile_notifications.zig"),
4242 @embedFile("src/bridge_mobile_bgtasks.zig"),
4343 @embedFile("src/bridge_mobile_watch.zig"),
44 @embedFile("src/bridge_mobile_location.zig"),
45 @embedFile("src/bridge_mobile_locrecording.zig"),
46 @embedFile("src/bridge_mobile_motion.zig"),
4447};
4548
4649/// The action list lives in the `switch action` block, and nowhere else.
@@ -76,7 +79,9 @@ const dispatch_end = "func webView(";
7679/// sendToWatch all hand iOS a callback that fires later, which needs an event
7780/// channel iOS does not have yet. Falling through beats `.unavailable`, which
7881/// would make Zig dispatch and refuse an action the shim serves correctly.
79const max_not_yet_migrated: usize = 64;
82/// 58 after the event channel landed and unblocked the location tier: the
83/// first actions that push a stream to the page rather than answering a call.
84const max_not_yet_migrated: usize = 58;
8085
8186fn dispatcherRegion() []const u8 {
8287 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";