also looking at this
feat(ios): location recording moves to the manager it always shared — ratchet 23 -> 18
#125
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.
| @@ -94,26 +94,26 @@ | ||
| 94 | 94 | //! running. A watch that hits an error has no reply left to reject — its |
| 95 | 95 | //! `true` went out when it started — so the event is the only channel it has. |
| 96 | 96 | //! |
| 97 | //! **The recording side effect.** Swift's `didUpdateLocations` calls | |
| 97 | //! **The recording side effect, restored.** Swift's `didUpdateLocations` calls | |
| 98 | 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. | |
| 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. | |
| 105 | 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. | |
| 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. | |
| 117 | 117 | //! |
| 118 | 118 | //! The converse loss is gone rather than added to: a `getCurrentPosition` |
| 119 | 119 | //! issued while *this* module's watch is running is broadcast to the watch |
| @@ -767,26 +767,7 @@ pub const LocationBridge = struct { | ||
| 767 | 767 | var out: std.ArrayListUnmanaged(u8) = .empty; |
| 768 | 768 | defer out.deinit(self.allocator); |
| 769 | 769 | |
| 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); | |
| 790 | 771 | |
| 791 | 772 | switch (extra) { |
| 792 | 773 | .none => {}, |
| @@ -894,6 +875,43 @@ pub fn adoptRecording() bool { | ||
| 894 | 875 | return true; |
| 895 | 876 | } |
| 896 | 877 | |
| 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. | |
| 887 | fn 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 | ||
| 897 | 915 | /// What a summary reply carries beyond the four common keys. |
| 898 | 916 | const SummaryExtra = enum { none, with_locations, with_sample_count }; |
| 899 | 917 | |
| @@ -2858,3 +2876,138 @@ test "the delegate class registers under its own name, idempotently" { | ||
| 2858 | 2876 | return error.SelectorNotFound; |
| 2859 | 2877 | try testing.expect(!responds(first, sel_responds, sel_auth)); |
| 2860 | 2878 | } |
| 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. | |
| 2894 | fn resetRecordingForTest() void { | |
| 2895 | commitRecording(.{}, null) catch {}; | |
| 2896 | state_mutex.lock(); | |
| 2897 | defer state_mutex.unlock(); | |
| 2898 | watch_sels = null; | |
| 2899 | } | |
| 2900 | ||
| 2901 | test "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 | ||
| 2919 | test "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 | ||
| 2933 | test "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 | ||
| 2950 | test "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 | ||
| 2969 | test "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 | ||
| 2997 | test "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 | } | |
| @@ -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`. | |
| 3 | 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. | |
| 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. | |
| 14 | 13 | //! |
| 15 | 14 | //! One JS surface reaches these six, `CraftApp.swift:2379-2384`: |
| 16 | 15 | //! |
| @@ -127,86 +126,61 @@ | ||
| 127 | 126 | //! recording, and the alternative — a track silently cut off at 32 MiB and |
| 128 | 127 | //! reported as the whole one — is the failure this module exists to avoid. |
| 129 | 128 | //! |
| 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 | |
| 165 | 130 | //! |
| 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. | |
| 174 | 135 | //! |
| 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. | |
| 180 | 156 | //! |
| 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. | |
| 193 | 164 | //! |
| 194 | 165 | //! ## No delegate, no manager, no event, no async |
| 195 | 166 | //! |
| 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, | |
| 197 | 168 | //! 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 | |
| 200 | 172 | //! `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback WebKit |
| 201 | 173 | //! delivers on the main thread, so the reply is sent from the main thread with |
| 202 | 174 | //! no hop — the reasoning is written out at `bridge_mobile_display.zig`. |
| 203 | 175 | //! |
| 204 | 176 | //! No mutex either, and that is not an oversight. The premise of a |
| 205 | 177 | //! 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`. | |
| 210 | 184 | |
| 211 | 185 | const std = ; |
| 212 | 186 | const builtin = ; |
| @@ -231,10 +205,12 @@ const Id = ?*anyopaque; | ||
| 231 | 205 | /// `test/ios_conformance_test.zig` matches the two lists by string in both |
| 232 | 206 | /// directions. |
| 233 | 207 | /// |
| 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. | |
| 238 | 214 | pub const A = struct { |
| 239 | 215 | pub const read_location_recording = "readLocationRecording"; |
| 240 | 216 | }; |
| @@ -1159,18 +1135,15 @@ test "every route the dispatcher has is a declared action" { | ||
| 1159 | 1135 | } |
| 1160 | 1136 | } |
| 1161 | 1137 | |
| 1162 | test "the five stateful recording actions are left to the Swift shim" { | |
| 1163 | // The deliberate omissions, asserted so they cannot be "fixed" by accident. | |
| 1138 | test "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. | |
| 1164 | 1141 | // |
| 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. | |
| 1174 | 1147 | const unserved = [_][]const u8{ |
| 1175 | 1148 | "startLocationRecording", |
| 1176 | 1149 | "pauseLocationRecording", |