ReviewOS

also looking at this

craft-native/craft

feat(ios): location recording moves to the manager it always shared — ratchet 23 -> 18

#125
Merged glennmichael123 wants to merge feat/ios-location-recording into main
6 files +1653 -170

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig/src/bridge_mobile_location.zigmodified+191-38
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -94,26 +94,26 @@
9494//! running. A watch that hits an error has no reply left to reject — its
9595//! `true` went out when it started — so the event is the only channel it has.
9696//!
97//! **The recording side effect.** Swift's `didUpdateLocations` calls
97//! **The recording side effect, restored.** Swift's `didUpdateLocations` calls
9898//! `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.
99//! `requestLocation`, so a `getCurrentPosition` during an active recording
100//! appends one extra sample to the track. While the recorder was on the shim
101//! and this module had a manager of its own, that sample was no longer added —
102//! a behaviour change a page counting samples could see. The recorder now runs
103//! on this manager, so the one-shot's fix reaches the track again, exactly as
104//! it does in the spec.
105105//!
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.
106//! **One `craftLocationUpdate` per fix, again.** A paragraph here used to
107//! record a duplication: the six `*LocationRecording` actions stayed with the
108//! shim, so a recording ran Swift's manager and Swift's `didUpdateLocations`,
109//! which fires `craftLocationUpdate` whenever `isRecordingLocation` is true —
110//! watch or no watch. A page that recorded *and* watched therefore had two
111//! managers feeding one event name and saw roughly two events per fix. It
112//! ended, as that paragraph predicted, when the recording actions migrated:
113//! five of the six now live in this file, on this manager, and the sixth reads
114//! a file. The emit guard below is Swift's own,
115//! `if isWatchingLocation || isRecordingLocation`, and one manager satisfies
116//! it once.
117117//!
118118//! The converse loss is gone rather than added to: a `getCurrentPosition`
119119//! issued while *this* module's watch is running is broadcast to the watch
@@ -767,26 +767,7 @@ pub const LocationBridge = struct {
767767 var out: std.ArrayListUnmanaged(u8) = .empty;
768768 defer out.deinit(self.allocator);
769769
770 try out.appendSlice(self.allocator, "{\"id\":");
771 if (snapshot.id) |id| {
772 try out.append(self.allocator, '"');
773 try bridge_error.appendJsonEscaped(self.allocator, &out, id);
774 try out.append(self.allocator, '"');
775 } else {
776 try out.appendSlice(self.allocator, "null");
777 }
778 try out.appendSlice(self.allocator, ",\"active\":");
779 try out.appendSlice(self.allocator, if (snapshot.active) "true" else "false");
780 try out.appendSlice(self.allocator, ",\"paused\":");
781 try out.appendSlice(self.allocator, if (snapshot.paused) "true" else "false");
782 try out.appendSlice(self.allocator, ",\"startedAt\":");
783 if (snapshot.started_at) |started| {
784 const rendered = try std.fmt.allocPrint(self.allocator, "{d}", .{started});
785 defer self.allocator.free(rendered);
786 try out.appendSlice(self.allocator, rendered);
787 } else {
788 try out.appendSlice(self.allocator, "null");
789 }
770 try appendSummaryFields(self.allocator, &out, snapshot);
790771
791772 switch (extra) {
792773 .none => {},
@@ -894,6 +875,43 @@ pub fn adoptRecording() bool {
894875 return true;
895876}
896877
878/// `locationRecordingSummary()`: the four keys, in Swift's order.
879///
880/// Split out from the reply so the shape is pinnable without a
881/// `CLLocationManager` — the keys, their order, and the two that are `NSNull`
882/// on the wire rather than empty strings or zeroes.
883///
884/// Writes the opening brace and the four keys, and **leaves the object open**:
885/// `stopLocationRecording` and `getLocationRecordingState` each add one more
886/// key, and the caller closes it.
887fn appendSummaryFields(
888 allocator: std.mem.Allocator,
889 out: *std.ArrayListUnmanaged(u8),
890 state: locrec.State,
891) !void {
892 try out.appendSlice(allocator, "{\"id\":");
893 if (state.id) |id| {
894 // `appendJsonEscaped` writes no delimiters; the quotes are the caller's.
895 try out.append(allocator, '"');
896 try bridge_error.appendJsonEscaped(allocator, out, id);
897 try out.append(allocator, '"');
898 } else {
899 try out.appendSlice(allocator, "null");
900 }
901 try out.appendSlice(allocator, ",\"active\":");
902 try out.appendSlice(allocator, if (state.active) "true" else "false");
903 try out.appendSlice(allocator, ",\"paused\":");
904 try out.appendSlice(allocator, if (state.paused) "true" else "false");
905 try out.appendSlice(allocator, ",\"startedAt\":");
906 if (state.started_at) |started| {
907 const rendered = try std.fmt.allocPrint(allocator, "{d}", .{started});
908 defer allocator.free(rendered);
909 try out.appendSlice(allocator, rendered);
910 } else {
911 try out.appendSlice(allocator, "null");
912 }
913}
914
897915/// What a summary reply carries beyond the four common keys.
898916const SummaryExtra = enum { none, with_locations, with_sample_count };
899917
@@ -2858,3 +2876,138 @@ test "the delegate class registers under its own name, idempotently" {
28582876 return error.SelectorNotFound;
28592877 try testing.expect(!responds(first, sel_responds, sel_auth));
28602878}
2879
2880// =============================================================================
2881// The recorder.
2882//
2883// Two claims are worth pinning without a CLLocationManager: the summary's
2884// shape, which is what every one of the five replies with, and the arbitration
2885// between the watch and the recorder, which is the pair of guards that stop one
2886// from ending the other's stream.
2887// =============================================================================
2888
2889/// Leave the module-level recorder state as the tests found it.
2890///
2891/// These tests mutate process-wide state that the delegate also reads, so each
2892/// one puts it back — an escaped `active` recording would make a later test's
2893/// `didUpdateLocations` try to append to a track that is not there.
2894fn resetRecordingForTest() void {
2895 commitRecording(.{}, null) catch {};
2896 state_mutex.lock();
2897 defer state_mutex.unlock();
2898 watch_sels = null;
2899}
2900
2901test "the summary is the four keys in Swift's order" {
2902 var out: std.ArrayListUnmanaged(u8) = .empty;
2903 defer out.deinit(testing.allocator);
2904
2905 try appendSummaryFields(testing.allocator, &out, .{
2906 .active = true,
2907 .paused = false,
2908 .id = "ABC-123",
2909 .started_at = 1756900000000,
2910 });
2911
2912 // No closing brace: `stopLocationRecording` and `getLocationRecordingState`
2913 // add a fifth key after these four, so the caller closes the object.
2914 try testing.expectEqualStrings(
2915 \\{"id":"ABC-123","active":true,"paused":false,"startedAt":1756900000000
2916 , out.items);
2917}
2918
2919test "a recording that never started reports nulls, not empty strings or zeroes" {
2920 var out: std.ArrayListUnmanaged(u8) = .empty;
2921 defer out.deinit(testing.allocator);
2922
2923 // `locationRecordingSummary()` puts `NSNull` in both slots, and
2924 // `resolveCallback` serialises that as `null`. A page testing
2925 // `state.id === null` has to keep working.
2926 try appendSummaryFields(testing.allocator, &out, .{});
2927
2928 try testing.expectEqualStrings(
2929 \\{"id":null,"active":false,"paused":false,"startedAt":null
2930 , out.items);
2931}
2932
2933test "the subscription follows the state, so the track and the summary cannot disagree" {
2934 defer resetRecordingForTest();
2935
2936 // Collecting is exactly `active and !paused` — `appendRecordedLocation`'s
2937 // own guard — and `recording_sels` is how `didUpdateLocations` reads it.
2938 const sels = Sels.resolve() catch return error.SkipZigTest;
2939
2940 try commitRecording(.{ .active = true, .paused = false, .id = "r1" }, sels);
2941 try testing.expect(recordingHoldsTheManager());
2942
2943 try commitRecording(.{ .active = true, .paused = true, .id = "r1" }, null);
2944 try testing.expect(!recordingHoldsTheManager());
2945
2946 try commitRecording(.{ .active = false, .paused = false, .id = "r1" }, null);
2947 try testing.expect(!recordingHoldsTheManager());
2948}
2949
2950test "a stop keeps the id and startedAt, which is why a relaunch differs from a stop" {
2951 defer resetRecordingForTest();
2952
2953 const sels = Sels.resolve() catch return error.SkipZigTest;
2954 try commitRecording(.{ .active = true, .id = "r1", .started_at = 42 }, sels);
2955
2956 // `stopLocationRecording` clears neither, so a read in the same launch
2957 // still reports them. Only a relaunch — where `readState`'s guard runs —
2958 // answers null. The two halves of #124's third blocker, from this side.
2959 try commitRecording(.{ .active = false, .id = "r1", .started_at = 42 }, null);
2960
2961 var snapshot = try snapshotRecording(testing.allocator);
2962 defer snapshot.deinit(testing.allocator);
2963
2964 try testing.expect(!snapshot.active);
2965 try testing.expectEqualStrings("r1", snapshot.id.?);
2966 try testing.expectEqual(@as(f64, 42), snapshot.started_at.?);
2967}
2968
2969test "the watch and the recorder each hold the shared manager against the other" {
2970 defer resetRecordingForTest();
2971
2972 const sels = Sels.resolve() catch return error.SkipZigTest;
2973
2974 // `stopWatchingPosition`'s `if !isRecordingLocation`, and its mirror in
2975 // `pauseLocationRecording` / `stopLocationRecording`. Before the recorder
2976 // moved here neither guard could fail, because the two runtimes owned
2977 // different managers; now one manager serves both and whichever finishes
2978 // second must not end the other's stream.
2979 try testing.expect(!recordingHoldsTheManager());
2980 try testing.expect(!watchHoldsTheManager());
2981
2982 try commitRecording(.{ .active = true, .id = "r1" }, sels);
2983 publishWatch(sels);
2984 try testing.expect(recordingHoldsTheManager());
2985 try testing.expect(watchHoldsTheManager());
2986
2987 // The watch stops: the recorder is still collecting, so the manager stays.
2988 _ = takeWatchAndPending();
2989 try testing.expect(!watchHoldsTheManager());
2990 try testing.expect(recordingHoldsTheManager());
2991
2992 // And the recorder stops last, with nothing left holding it.
2993 try commitRecording(.{ .active = false, .id = "r1" }, null);
2994 try testing.expect(!recordingHoldsTheManager());
2995}
2996
2997test "a paused recording is not a consumer, so its fixes are not written" {
2998 defer resetRecordingForTest();
2999
3000 const sels = Sels.resolve() catch return error.SkipZigTest;
3001 try commitRecording(.{ .active = true, .paused = true, .id = "r1" }, null);
3002
3003 // `didUpdateLocations` reads one thing to decide both whether to append and
3004 // which names to shape with. A paused recording must not appear in it.
3005 const work = takeDelegateWork();
3006 try testing.expect(work.recording == null);
3007 try testing.expect(work.watch == null);
3008 try testing.expect(work.pending == null);
3009
3010 // And resuming puts it back.
3011 try commitRecording(.{ .active = true, .paused = false, .id = "r1" }, sels);
3012 try testing.expect(takeDelegateWork().recording != null);
3013}
packages/zig/src/bridge_mobile_locrecording.zigmodified+67-94
Changes to packages/zig/src/bridge_mobile_locrecording.zig
@@ -1,16 +1,15 @@
1//! The one location-recording action of the `mobile` namespace that Zig can
2//! serve honestly: `readLocationRecording`.
1//! The on-disk half of a location recording: the track, the state mirror, and
2//! the one action that only reads them — `readLocationRecording`.
33//!
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.
4//! The other five live in `bridge_mobile_location.zig`, with the
5//! `CLLocationManager` they share. That split follows the spec rather than
6//! cutting across it: in `CraftWebView.Coordinator` a recording is not a device
7//! of its own, it is a flag on the one delegate plus these two files, and the
8//! flag belongs next to the delegate while the files belong here.
9//!
10//! This module keeps no manager, registers no Objective-C class and emits on no
11//! channel. It does now *write*, which it did not when it only read the track,
12//! so the format is stated once here and used from both sides.
1413//!
1514//! One JS surface reaches these six, `CraftApp.swift:2379-2384`:
1615//!
@@ -127,86 +126,61 @@
127126//! recording, and the alternative — a track silently cut off at 32 MiB and
128127//! reported as the whole one — is the failure this module exists to avoid.
129128//!
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.
129//! ## What it took to move the other five, and what had gone stale
165130//!
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.
131//! This section used to argue that the five could not move. Two of its three
132//! blockers had already been removed by other work and nothing re-checked them;
133//! the audit is #124, and the short version is worth keeping because the shape
134//! of the mistake is more useful than the conclusion was.
174135//!
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.
136//! - **"`enableGeolocation` has no mirror anywhere in `packages/zig/src`."**
137//! True when written. `ios_config.zig` now reads the same
138//! `craft.config.json` Swift decodes and exposes that flag, and its own
139//! header names this note as one of the two it closes. Only
140//! `startLocationRecording` is gated in the spec, so it is the only one of
141//! the five in `ios_config.gateFor`.
142//! - **"It needs an event channel that does not exist."** The paragraph
143//! checked `capabilities.Channel`, where `.location_update` really is
144//! `"craft:location:update"` and really is listened for by nothing on iOS.
145//! But the iOS event path is `ios_events.Event`, whose `.location_update`
146//! is `"craftLocationUpdate"` — the name the recorder needs — and
147//! `bridge_mobile_location.zig` was already emitting on it for
148//! `watchPosition`, one module away.
149//! - **"`getLocationRecordingState` answers from memory, and a file-reading
150//! Zig would diverge."** This one was correct, and it is the reason
151//! `decodeState` exists in the shape it does. Swift's restore guard,
152//! `state["active"] as? Bool == true`, means a *stopped* recording — whose
153//! file still carries the old `id` and `startedAt` — restores as nothing at
154//! all. Ported literally, so a stop-then-read in one launch still reports
155//! the id and a relaunch still reports null.
180156//!
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.
157//! The fourth obstacle was real and was never written down here: SwiftUI runs
158//! `makeCoordinator()` before `makeUIView`, so `Coordinator.init` — and
159//! `restoreLocationRecordingState()` inside it — ran before
160//! `craft_ios_set_webview` had been called even once. Whichever runtime
161//! restores first owns the manager for the rest of the launch. That is settled
162//! by `craft_ios_adopt_location_recording`, which Swift consults *instead of*
163//! its own restore and falls back from when `dlsym` finds no runtime.
193164//!
194165//! ## No delegate, no manager, no event, no async
195166//!
196//! This module reads one file and replies. It registers no Objective-C class,
167//! This module reads and writes two files. It registers no Objective-C class,
197168//! 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
169//! channel, and takes no `ios_async` ticket: every one of these operations is
170//! synchronous, as `loadRecordedLocations()` and
171//! `persistLocationRecordingState()` both are. `ios_dispatch.handleMessage` runs from
200172//! `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback WebKit
201173//! delivers on the main thread, so the reply is sent from the main thread with
202174//! no hop — the reasoning is written out at `bridge_mobile_display.zig`.
203175//!
204176//! No mutex either, and that is not an oversight. The premise of a
205177//! 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.
178//! `appendSample`, called from `didUpdateLocations`, which CoreLocation
179//! delivers on the thread that created the manager — the main thread — and the
180//! readers are dispatches, also on the main thread. The shared state is a file,
181//! and the two never run concurrently. The recorder's *in-memory* state does
182//! take a mutex, and it takes it where it lives, in
183//! `bridge_mobile_location.zig`.
210184
211185const std = ;
212186const builtin = ;
@@ -231,10 +205,12 @@ const Id = ?*anyopaque;
231205/// `test/ios_conformance_test.zig` matches the two lists by string in both
232206/// directions.
233207///
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.
208/// The other five are served by `bridge_mobile_location.zig`, which owns the
209/// `CLLocationManager` they share with the watch. They are not absent from the
210/// migration, only from this file — and `bridge_mobile_location.zig`'s own
211/// routing test asserts this module does not answer them, so the split cannot
212/// quietly become an overlap that makes `ios_dispatch`'s first-match order
213/// significant.
238214pub const A = struct {
239215 pub const read_location_recording = "readLocationRecording";
240216};
@@ -1159,18 +1135,15 @@ test "every route the dispatcher has is a declared action" {
11591135 }
11601136}
11611137
1162test "the five stateful recording actions are left to the Swift shim" {
1163 // The deliberate omissions, asserted so they cannot be "fixed" by accident.
1138test "the five stateful recording actions belong to the manager's module, not this one" {
1139 // Not a deferral any more — an ownership boundary, asserted from this side
1140 // so it cannot quietly become an overlap.
11641141 //
1165 // `ios_dispatch`'s chain treats UnknownAction as "not mine, ask the next"
1166 // and any other error as final. So the *only* spelling of "let the shim
1167 // keep serving this" is: absent from `A`, absent from `routeFor`, and
1168 // UnknownAction out of `handleMessage`. An `.unavailable` declaration would
1169 // dispatch and refuse five actions the shim answers correctly today —
1170 // including the `enableGeolocation` gate, the authorization mode,
1171 // background continuation, atomic persistence, file protection, backup
1172 // exclusion and relaunch restore, none of which Zig can reproduce while
1173 // `Coordinator.init` still re-adopts the recording on every launch.
1142 // `ios_dispatch` routes first-match, so two modules answering one action
1143 // would make the answer depend on the order of the module list. The five
1144 // lifecycle actions need the `CLLocationManager` and the delegate, which
1145 // live in `bridge_mobile_location.zig`; this file owns the two files they
1146 // read and write. `bridge_mobile_location.zig` asserts the converse.
11741147 const unserved = [_][]const u8{
11751148 "startLocationRecording",
11761149 "pauseLocationRecording",