also looking at this
iOS Phase 7: location and motion streams — ratchet 64 → 58
#89
9 files
+5347
-2
| @@ -162,4 +162,14 @@ C14="$(count 14)" | ||
| 162 | 162 | [ "$C14" -ge 1 ] || { echo "FAIL: the async array reply never reached the page"; exit 1; } |
| 163 | 163 | echo "ok: async NSArray completion delivered via ios_async (i=14 seen ${C14}x)" |
| 164 | 164 | |
| 165 | C16="$(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; } | |
| 169 | echo "ok: unavailable sensor refused rather than faking a stream (i=16 absent)" | |
| 170 | ||
| 171 | C60="$(count 60)" | |
| 172 | [ "$C60" -ge 1 ] || { echo "FAIL: startMotionUpdates never reached the dispatcher"; exit 1; } | |
| 173 | echo "ok: event-driven action dispatched to Zig (i=60 seen ${C60}x)" | |
| 174 | ||
| 165 | 175 | echo "PASS" |
| @@ -30,6 +30,10 @@ const char craft_slice_page[] = | ||
| 30 | 30 | "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" |
| 31 | 31 | "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" |
| 32 | 32 | "</head><body><h1 id=\"s\">craft slice</h1><script>\n" |
| 33 | "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" | |
| 33 | 37 | "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n" |
| 34 | 38 | "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n" |
| 35 | 39 | "\n" |
| @@ -116,7 +120,10 @@ const char craft_slice_page[] = | ||
| 116 | 120 | " // not formatted), then read it back through dbQuery.\n" |
| 117 | 121 | " 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" |
| 118 | 122 | " 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" | |
| 120 | 127 | " // A Tier-0 action migrated in this phase, served by Zig directly.\n" |
| 121 | 128 | " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n" |
| 122 | 129 | " // And one the shim answers by rejecting, to prove the error route.\n" |
| @@ -449,6 +449,15 @@ pub fn build(b: *std.Build) void { | ||
| 449 | 449 | ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_watch.zig", .{ |
| 450 | 450 | .root_source_file = b.path("src/bridge_mobile_watch.zig"), |
| 451 | 451 | }); |
| 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 | }); | |
| 452 | 461 | |
| 453 | 462 | const menubar_tests = b.addTest(.{ |
| 454 | 463 | .root_module = b.createModule(.{ |
packages/zig/src/bridge_mobile_location.zigadded+2268-0
| @@ -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 | ||
| 281 | const std = ; | |
| 282 | const builtin = ; | |
| 283 | const capabilities = ; | |
| 284 | const bridge_error = ; | |
| 285 | const objc_runtime = ; | |
| 286 | const ios_async = ; | |
| 287 | const ios_events = ; | |
| 288 | const compat_mutex = ; | |
| 289 | ||
| 290 | const objc = objc_runtime.objc; | |
| 291 | const 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)`. | |
| 299 | const 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. | |
| 309 | pub 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. | |
| 331 | pub 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. | |
| 343 | const 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. | |
| 348 | const Route = enum { current_position, watch_position, clear_watch }; | |
| 349 | ||
| 350 | fn 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 | ||
| 357 | pub 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. | |
| 574 | fn 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. | |
| 593 | const 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. | |
| 611 | fn 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. | |
| 651 | fn 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. | |
| 677 | fn 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. | |
| 690 | const Authorization = enum { when_in_use, always }; | |
| 691 | ||
| 692 | /// Emitted into Info.plist by `packages/ios/src/index.ts` iff | |
| 693 | /// `enableGeolocation || enableBackgroundLocation`. | |
| 694 | const key_when_in_use = "NSLocationWhenInUseUsageDescription"; | |
| 695 | ||
| 696 | /// Emitted iff `enableBackgroundLocation` — the same condition that selects | |
| 697 | /// `requestAlwaysAuthorization` in Swift. | |
| 698 | const 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. | |
| 732 | fn 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. | |
| 756 | fn 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`. | |
| 774 | fn 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. | |
| 780 | const 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`. | |
| 800 | fn 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. | |
| 845 | fn 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. | |
| 874 | const 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. | |
| 880 | var 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. | |
| 889 | var manager: Id = null; | |
| 890 | ||
| 891 | fn 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. | |
| 897 | fn 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. | |
| 945 | extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; | |
| 946 | ||
| 947 | /// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin. | |
| 948 | const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); | |
| 949 | ||
| 950 | fn 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. | |
| 959 | const 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. | |
| 972 | fn 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. | |
| 1034 | const 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 | ||
| 1092 | const PendingFix = struct { | |
| 1093 | ticket: ios_async.Ticket, | |
| 1094 | sels: Sels, | |
| 1095 | }; | |
| 1096 | ||
| 1097 | var 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. | |
| 1107 | var 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. | |
| 1117 | var 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. | |
| 1121 | fn 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. | |
| 1131 | fn 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. | |
| 1141 | fn 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. | |
| 1148 | const 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 | ||
| 1157 | fn 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. | |
| 1166 | const 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. | |
| 1187 | fn 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 | ||
| 1210 | fn 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 | ||
| 1252 | fn 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. | |
| 1299 | fn 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. | |
| 1323 | fn 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. | |
| 1344 | const 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. | |
| 1351 | fn 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. | |
| 1388 | fn 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. | |
| 1398 | fn 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 | ||
| 1427 | const testing = std.testing; | |
| 1428 | ||
| 1429 | test "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 | ||
| 1449 | test "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 | ||
| 1458 | test "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 | ||
| 1467 | test "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 | ||
| 1476 | test "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 | ||
| 1502 | test "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 | ||
| 1516 | test "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 | ||
| 1551 | test "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. | |
| 1600 | fn 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. | |
| 1611 | const 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 | ||
| 1622 | test "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 | ||
| 1643 | test "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 | ||
| 1673 | test "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 | ||
| 1707 | test "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 | ||
| 1725 | test "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 | ||
| 1759 | test "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 | ||
| 1792 | test "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 | ||
| 1819 | test "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 | ||
| 1838 | test "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 | ||
| 1860 | test "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 | ||
| 1881 | test "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 | ||
| 1897 | const 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 | ||
| 1909 | test "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 | ||
| 1927 | test "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 | ||
| 1950 | test "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 | ||
| 1972 | test "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 | ||
| 2006 | test "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 | ||
| 2033 | test "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 | ||
| 2057 | test "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 | ||
| 2074 | test "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 | ||
| 2092 | test "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 | ||
| 2112 | test "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 | ||
| 2160 | test "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 | ||
| 2200 | test "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 | ||
| 2221 | test "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
| @@ -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 | ||
| 211 | const std = ; | |
| 212 | const builtin = ; | |
| 213 | const capabilities = ; | |
| 214 | const bridge_error = ; | |
| 215 | const objc_runtime = ; | |
| 216 | const io_context = ; | |
| 217 | ||
| 218 | const objc = objc_runtime.objc; | |
| 219 | const 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. | |
| 228 | const 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. | |
| 238 | pub 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. | |
| 249 | pub 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. | |
| 255 | const Route = enum { read_recording }; | |
| 256 | ||
| 257 | fn 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. | |
| 268 | const 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. | |
| 277 | const max_track_bytes: u64 = 32 * 1024 * 1024; | |
| 278 | ||
| 279 | /// The reply for "there is no recording to read". | |
| 280 | const empty_array = "[]"; | |
| 281 | ||
| 282 | pub 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. | |
| 350 | fn 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. | |
| 413 | fn 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`. | |
| 437 | const ns_application_support_directory: c_ulong = 14; | |
| 438 | /// `NSUserDomainMask`. | |
| 439 | const ns_user_domain_mask: c_ulong = 1; | |
| 440 | /// `NSUTF8StringEncoding`. | |
| 441 | const 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. | |
| 453 | fn 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 | ||
| 460 | fn 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.) | |
| 495 | fn 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. | |
| 528 | fn 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 | ||
| 608 | const testing = std.testing; | |
| 609 | ||
| 610 | test "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 | ||
| 623 | test "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 | ||
| 630 | test "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 | ||
| 639 | test "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 | ||
| 665 | test "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 | ||
| 700 | test "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. | |
| 745 | fn 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 | ||
| 753 | test "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 | ||
| 769 | test "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 | ||
| 789 | fn 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. | |
| 797 | const 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 | ||
| 802 | test "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 | ||
| 813 | test "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 | ||
| 832 | test "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 | ||
| 855 | test "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 | ||
| 866 | test "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 | ||
| 870 | test "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 | ||
| 891 | test "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 | ||
| 901 | test "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 | ||
| 916 | test "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 | ||
| 930 | test "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 | ||
| 940 | test "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 | ||
| 957 | test "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 | ||
| 989 | test "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 | ||
| 1000 | test "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 | ||
| 1011 | test "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. | |
| 1038 | const test_track_name = "craft-locrecording-test-track.jsonl"; | |
| 1039 | ||
| 1040 | fn 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 | ||
| 1047 | fn 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 | ||
| 1053 | test "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 | ||
| 1060 | test "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 | ||
| 1074 | test "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 | ||
| 1088 | test "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 | ||
| 1105 | test "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 | ||
| 1127 | test "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 | ||
| 1144 | test "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 | ||
| 1161 | test "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
| @@ -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 | ||
| 191 | const std = ; | |
| 192 | const builtin = ; | |
| 193 | const capabilities = ; | |
| 194 | const bridge_error = ; | |
| 195 | const objc_runtime = ; | |
| 196 | const ios_events = ; | |
| 197 | const compat_mutex = ; | |
| 198 | ||
| 199 | const objc = objc_runtime.objc; | |
| 200 | const 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)`. | |
| 208 | const 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. | |
| 213 | pub 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. | |
| 227 | pub 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. | |
| 237 | pub 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. | |
| 241 | pub 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. | |
| 245 | pub 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}`. | |
| 249 | const 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. | |
| 254 | const 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. | |
| 259 | const Route = enum { start, stop }; | |
| 260 | ||
| 261 | fn 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 | ||
| 267 | pub 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. | |
| 389 | fn 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. | |
| 408 | fn 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 | ||
| 429 | fn 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. | |
| 440 | var manager: Id = null; | |
| 441 | ||
| 442 | fn 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`). | |
| 467 | fn 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. | |
| 485 | const 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 | ||
| 510 | const 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 | ||
| 524 | var 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. | |
| 531 | var stream_mutex: compat_mutex.Mutex = .{}; | |
| 532 | ||
| 533 | fn armStream(sels: Sels) void { | |
| 534 | stream_mutex.lock(); | |
| 535 | defer stream_mutex.unlock(); | |
| 536 | stream = .{ .sels = sels, .fault_logged = false }; | |
| 537 | } | |
| 538 | ||
| 539 | fn disarmStream() void { | |
| 540 | stream_mutex.lock(); | |
| 541 | defer stream_mutex.unlock(); | |
| 542 | stream = .{}; | |
| 543 | } | |
| 544 | ||
| 545 | fn streamSels() ?Sels { | |
| 546 | stream_mutex.lock(); | |
| 547 | defer stream_mutex.unlock(); | |
| 548 | return stream.sels; | |
| 549 | } | |
| 550 | ||
| 551 | fn 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`. | |
| 558 | fn 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 | ||
| 570 | const BlockDescriptor = extern struct { | |
| 571 | reserved: c_ulong = 0, | |
| 572 | size: c_ulong, | |
| 573 | }; | |
| 574 | ||
| 575 | /// `CMDeviceMotionHandler` — `void (^)(CMDeviceMotion *motion, NSError *error)`. | |
| 576 | const 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. | |
| 588 | const BLOCK_IS_GLOBAL: c_int = 1 << 28; | |
| 589 | ||
| 590 | extern var _NSConcreteGlobalBlock: anyopaque; | |
| 591 | ||
| 592 | const 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. | |
| 597 | const 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. | |
| 620 | fn 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. | |
| 672 | const 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. | |
| 676 | const 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. | |
| 686 | const acceleration_entry: MsgSendEntry = if (!is_darwin) | |
| 687 | undefined | |
| 688 | else if (builtin.target.cpu.arch == .x86_64) | |
| 689 | &objc.objc_msgSend_stret | |
| 690 | else | |
| 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. | |
| 698 | fn 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. | |
| 738 | fn 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. | |
| 775 | pub 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`. | |
| 804 | pub 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. | |
| 812 | pub 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. | |
| 822 | pub 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. | |
| 844 | fn 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. | |
| 853 | fn 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. | |
| 880 | pub 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 | ||
| 931 | const testing = std.testing; | |
| 932 | ||
| 933 | test "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 | ||
| 947 | test "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 | ||
| 964 | test "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 | ||
| 972 | test "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 | ||
| 995 | test "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 | ||
| 1009 | test "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 | ||
| 1033 | test "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 | ||
| 1049 | test "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 | ||
| 1069 | test "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 | ||
| 1086 | test "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 | ||
| 1111 | test "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 | ||
| 1143 | test "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 | ||
| 1181 | test "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 | ||
| 1195 | test "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 | ||
| 1218 | test "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 | ||
| 1234 | test "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 | ||
| 1248 | test "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 | ||
| 1264 | test "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 | ||
| 1283 | fn 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 | ||
| 1289 | test "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 | ||
| 1298 | test "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 | ||
| 1308 | test "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 | ||
| 1321 | test "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 | ||
| 1331 | test "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 | ||
| 1343 | test "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 | ||
| 1354 | test "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. | |
| 1377 | fn 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 | ||
| 1386 | const 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 | ||
| 1392 | test "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 | ||
| 1417 | test "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 | ||
| 1434 | test "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 | ||
| 1456 | test "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 | ||
| 1494 | test "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 | ||
| 1500 | test "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 | ||
| 1508 | test "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 | ||
| 1530 | test "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 | } | |
| @@ -4,6 +4,7 @@ const objc_runtime = ; | ||
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | 6 | pub const ios_async = ; |
| 7 | pub const ios_events = ; | |
| 7 | 8 | const bridge_mobile = ; |
| 8 | 9 | const bridge_mobile_clipboard = ; |
| 9 | 10 | const bridge_mobile_haptics = ; |
| @@ -21,6 +22,9 @@ const bridge_mobile_notifcancel = ; | ||
| 21 | 22 | const bridge_mobile_notifications = ; |
| 22 | 23 | const bridge_mobile_bgtasks = ; |
| 23 | 24 | const bridge_mobile_watch = ; |
| 25 | const bridge_mobile_location = ; | |
| 26 | const bridge_mobile_locrecording = ; | |
| 27 | const bridge_mobile_motion = ; | |
| 24 | 28 | |
| 25 | 29 | const objc = objc_runtime.objc; |
| 26 | 30 | |
| @@ -225,6 +229,9 @@ const mobile_bridges = .{ | ||
| 225 | 229 | bridge_mobile_notifications.NotificationsBridge, |
| 226 | 230 | bridge_mobile_bgtasks.BgTasksBridge, |
| 227 | 231 | bridge_mobile_watch.WatchBridge, |
| 232 | bridge_mobile_location.LocationBridge, | |
| 233 | bridge_mobile_locrecording.LocationRecordingBridge, | |
| 234 | bridge_mobile_motion.MotionBridge, | |
| 228 | 235 | }; |
| 229 | 236 | |
| 230 | 237 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| @@ -380,6 +387,7 @@ fn buildShimError( | ||
| 380 | 387 | |
| 381 | 388 | test { |
| 382 | 389 | _ = ios_async; |
| 390 | _ = ios_events; | |
| 383 | 391 | } |
| 384 | 392 | |
| 385 | 393 | const testing = std.testing; |
| @@ -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 | ||
| 37 | const std = ; | |
| 38 | const builtin = ; | |
| 39 | const capabilities = ; | |
| 40 | const 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. | |
| 48 | pub 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. | |
| 99 | const 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. | |
| 114 | const max_queued = 16; | |
| 115 | ||
| 116 | var queue: [max_queued]Pending = @splat(.{}); | |
| 117 | var 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. | |
| 124 | var dropped: usize = 0; | |
| 125 | ||
| 126 | pub fn droppedCount() usize { | |
| 127 | queue_mutex.lock(); | |
| 128 | defer queue_mutex.unlock(); | |
| 129 | return dropped; | |
| 130 | } | |
| 131 | ||
| 132 | const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; | |
| 133 | extern "c" fn dispatch_async_f(queue_ref: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void; | |
| 134 | extern 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. | |
| 144 | pub 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 | ||
| 178 | fn findFreeSlot() ?usize { | |
| 179 | for (&queue, 0..) |*slot, i| { | |
| 180 | if (!slot.in_use) return i; | |
| 181 | } | |
| 182 | return null; | |
| 183 | } | |
| 184 | ||
| 185 | fn 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. | |
| 223 | fn 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 | ||
| 235 | const testing = std.testing; | |
| 236 | ||
| 237 | test "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 | ||
| 250 | test "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 | ||
| 268 | test "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 | ||
| 278 | test "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 | } | |
| @@ -41,6 +41,9 @@ const zig_sources = [_][]const u8{ | ||
| 41 | 41 | @embedFile("src/bridge_mobile_notifications.zig"), |
| 42 | 42 | @embedFile("src/bridge_mobile_bgtasks.zig"), |
| 43 | 43 | @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"), | |
| 44 | 47 | }; |
| 45 | 48 | |
| 46 | 49 | /// The action list lives in the `switch action` block, and nowhere else. |
| @@ -76,7 +79,9 @@ const dispatch_end = "func webView("; | ||
| 76 | 79 | /// sendToWatch all hand iOS a callback that fires later, which needs an event |
| 77 | 80 | /// channel iOS does not have yet. Falling through beats `.unavailable`, which |
| 78 | 81 | /// would make Zig dispatch and refuse an action the shim serves correctly. |
| 79 | const 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. | |
| 84 | const max_not_yet_migrated: usize = 58; | |
| 80 | 85 | |
| 81 | 86 | fn dispatcherRegion() []const u8 { |
| 82 | 87 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |