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+638-46
Changes to packages/zig/src/bridge_mobile_location.zig
@@ -285,6 +285,8 @@ const bridge_error = ;
285285const objc_runtime = ;
286286const ios_async = ;
287287const ios_events = ;
288const locrec = ;
289const compat = ;
288290const compat_mutex = ;
289291
290292const objc = objc_runtime.objc;
@@ -310,6 +312,11 @@ pub const A = struct {
310312 pub const get_current_position = "getCurrentPosition";
311313 pub const watch_position = "watchPosition";
312314 pub const clear_watch = "clearWatch";
315 pub const start_location_recording = "startLocationRecording";
316 pub const pause_location_recording = "pauseLocationRecording";
317 pub const resume_location_recording = "resumeLocationRecording";
318 pub const stop_location_recording = "stopLocationRecording";
319 pub const get_location_recording_state = "getLocationRecordingState";
313320};
314321
315322/// `.result`: the Swift path terminates in exactly one `resolveCallback` (from
@@ -332,6 +339,21 @@ pub const capability_actions = [_]capabilities.ActionDecl{
332339 .{ .name = A.get_current_position, .reply = .result },
333340 .{ .name = A.watch_position, .reply = .result },
334341 .{ .name = A.clear_watch, .reply = .result },
342
343 // The recorder. Every one of the five ends in exactly one `resolveCallback`
344 // or one `rejectCallback`, so `.result` throughout; none of them is
345 // `.unavailable`, because each does the thing rather than refusing it.
346 //
347 // Only `startLocationRecording` is gated in the spec — `ios_config.gateFor`
348 // carries that one entry and not the other four, because `CraftApp.swift`
349 // guards that case alone. Stopping or reading a recording an app already
350 // made is not a geolocation capability, and refusing it would strand a
351 // route on disk with no action able to reach it.
352 .{ .name = A.start_location_recording, .reply = .result },
353 .{ .name = A.pause_location_recording, .reply = .result },
354 .{ .name = A.resume_location_recording, .reply = .result },
355 .{ .name = A.stop_location_recording, .reply = .result },
356 .{ .name = A.get_location_recording_state, .reply = .result },
335357};
336358
337359/// The reply `watchPosition` and `clearWatch` send.
@@ -345,12 +367,26 @@ const true_fragment = "true";
345367/// Which handler an action selects, split out from `handleMessage` so the
346368/// table-versus-dispatch agreement is assertable on a host without touching
347369/// CoreLocation.
348const Route = enum { current_position, watch_position, clear_watch };
370const Route = enum {
371 current_position,
372 watch_position,
373 clear_watch,
374 start_recording,
375 pause_recording,
376 resume_recording,
377 stop_recording,
378 recording_state,
379};
349380
350381fn routeFor(action: []const u8) ?Route {
351382 if (std.mem.eql(u8, action, A.get_current_position)) return .current_position;
352383 if (std.mem.eql(u8, action, A.watch_position)) return .watch_position;
353384 if (std.mem.eql(u8, action, A.clear_watch)) return .clear_watch;
385 if (std.mem.eql(u8, action, A.start_location_recording)) return .start_recording;
386 if (std.mem.eql(u8, action, A.pause_location_recording)) return .pause_recording;
387 if (std.mem.eql(u8, action, A.resume_location_recording)) return .resume_recording;
388 if (std.mem.eql(u8, action, A.stop_location_recording)) return .stop_recording;
389 if (std.mem.eql(u8, action, A.get_location_recording_state)) return .recording_state;
354390 return null;
355391}
356392
@@ -372,6 +408,11 @@ pub const LocationBridge = struct {
372408 .current_position => self.getCurrentPosition(data),
373409 .watch_position => self.watchPosition(data),
374410 .clear_watch => self.clearWatch(data),
411 .start_recording => self.startRecording(data),
412 .pause_recording => self.pauseRecording(data),
413 .resume_recording => self.resumeRecording(data),
414 .stop_recording => self.stopRecording(data),
415 .recording_state => self.recordingState(data),
375416 };
376417 }
377418
@@ -457,42 +498,16 @@ pub const LocationBridge = struct {
457498
458499 const authorization = try resolveAuthorization();
459500 const sels = try Sels.resolve();
501 const updates = try UpdateSelectors.resolve(authorization);
460502 const background = try backgroundLocationDeclared();
461503 const mgr = try ensureManager();
462504
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
473505 publishWatch(sels);
474506
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);
507 // The same lines `startLocationRecording` and `resumeLocationRecording`
508 // run. They share a manager, so they had better share the sequence that
509 // configures it.
510 applyUpdates(mgr, updates, background, .request);
496511
497512 bridge_error.sendResultToJS(self.allocator, A.watch_position, true_fragment);
498513 }
@@ -558,6 +573,20 @@ pub const LocationBridge = struct {
558573 return bridge_error.BridgeError.NativeCallFailed;
559574 };
560575
576 // `stopWatchingPosition()` is `isWatchingLocation = false` and then
577 // `if !isRecordingLocation { stopUpdatingLocation(); … }`. Before the
578 // recorder moved here that guard could never fail, because Swift owned
579 // every recording; now it can, and stopping the manager would silently
580 // end a route the page never asked to stop.
581 if (recordingHoldsTheManager()) {
582 std.log.info(
583 "clearWatch: the watch is stopped, but a recording is still using this manager; leaving it updating",
584 .{},
585 );
586 bridge_error.sendResultToJS(self.allocator, A.clear_watch, true_fragment);
587 return;
588 }
589
561590 objc.msgSend(mgr, sel_stop);
562591 // Mirrors `stopWatchingPosition()`'s `allowsBackgroundLocationUpdates =
563592 // false`. Setting it to NO is safe in any process — only YES can raise.
@@ -565,8 +594,498 @@ pub const LocationBridge = struct {
565594
566595 bridge_error.sendResultToJS(self.allocator, A.clear_watch, true_fragment);
567596 }
597
598 // =========================================================================
599 // The recorder.
600 //
601 // Five actions over one flag and two files. They share the manager with the
602 // watch, which is the whole reason they belong in this file rather than
603 // beside the track reader: `CraftWebView.Coordinator` runs one
604 // `CLLocationManager` for all of `getCurrentPosition`, `watchPosition` and
605 // the recorder, and two managers feeding one event name is the duplication
606 // this migration removes.
607 //
608 // Swift keeps no guard against starting a recording while one is running —
609 // `startLocationRecording` overwrites the track and the id unconditionally
610 // — and neither does this. It is a restart, and the page asked for one.
611 // =========================================================================
612
613 /// Begin a route, replacing whatever the last one left behind.
614 ///
615 /// The order differs from Swift's in one place, deliberately. Swift sets its
616 /// four variables, then calls `persistLocationRecordingState()`, which
617 /// catches its own write failure and prints. Memory and disk can therefore
618 /// disagree from the first sample onward, with the page told the recording
619 /// started. Here the state reaches disk *first*, and a failure refuses the
620 /// start: a recording the page is told is running is one that will still be
621 /// there after a relaunch.
622 fn startRecording(self: *Self, data: []const u8) !void {
623 _ = data;
624 if (!is_darwin) return error.UnsupportedPlatform;
625
626 // Everything that can fail, resolved before the track is cleared. A
627 // refusal after `resetTrack` would have destroyed the previous route to
628 // serve a call that then answers an error.
629 const authorization = try resolveAuthorization();
630 const sels = try Sels.resolve();
631 const updates = try UpdateSelectors.resolve(authorization);
632 const background = try backgroundLocationDeclared();
633 const mgr = try ensureManager();
634
635 // `commitRecording` copies it, so this frame owns it either way.
636 const id = try newRecordingId(recording_allocator);
637 defer recording_allocator.free(id);
638
639 const started_at = nowMillis();
640 const next: locrec.State = .{
641 .active = true,
642 .paused = false,
643 .id = id,
644 .started_at = started_at,
645 };
646
647 // `try Data().write(to:…, options:.atomic)`: a fresh, empty track. A
648 // storage failure refuses the start rather than appending this route on
649 // to the last one — `RECORDING_STORAGE_ERROR` in Swift, and the nearest
650 // `BridgeError` here, with the cause logged by the writer.
651 try locrec.resetTrack(self.allocator);
652 try locrec.writeState(self.allocator, next);
653
654 try commitRecording(next, sels);
655 applyUpdates(mgr, updates, background, .request);
656
657 try self.replySummary(A.start_location_recording, .none);
658 }
659
660 /// Stop collecting without ending the route.
661 ///
662 /// `guard isRecordingLocation else { reject("No active location recording") }`.
663 /// Paused is a state of a live recording, so a paused one still answers
664 /// `pause` — Swift's guard is on `isRecordingLocation` alone, not on
665 /// `!isLocationRecordingPaused`, and pausing twice is not an error.
666 fn pauseRecording(self: *Self, data: []const u8) !void {
667 _ = data;
668 if (!is_darwin) return error.UnsupportedPlatform;
669
670 var snapshot = try snapshotRecording(self.allocator);
671 defer snapshot.deinit(self.allocator);
672
673 if (!snapshot.active) return noActiveRecording();
674
675 const next: locrec.State = .{
676 .active = true,
677 .paused = true,
678 .id = snapshot.id,
679 .started_at = snapshot.started_at,
680 };
681 try locrec.writeState(self.allocator, next);
682 try commitRecording(next, null);
683
684 // `if !isWatchingLocation { locationManager?.stopUpdatingLocation() }`.
685 // The watch keeps the manager alive when it is the other user of it.
686 stopUpdatesUnlessWatched("pauseLocationRecording", .keep_background);
687
688 try self.replySummary(A.pause_location_recording, .none);
689 }
690
691 /// Collect again on a route that was paused.
692 fn resumeRecording(self: *Self, data: []const u8) !void {
693 _ = data;
694 if (!is_darwin) return error.UnsupportedPlatform;
695
696 const authorization = try resolveAuthorization();
697 const sels = try Sels.resolve();
698 const updates = try UpdateSelectors.resolve(authorization);
699 const background = try backgroundLocationDeclared();
700 const mgr = try ensureManager();
701
702 var snapshot = try snapshotRecording(self.allocator);
703 defer snapshot.deinit(self.allocator);
704
705 if (!snapshot.active) return noActiveRecording();
706
707 const next: locrec.State = .{
708 .active = true,
709 .paused = false,
710 .id = snapshot.id,
711 .started_at = snapshot.started_at,
712 };
713 try locrec.writeState(self.allocator, next);
714 try commitRecording(next, sels);
715
716 applyUpdates(mgr, updates, background, .request);
717
718 try self.replySummary(A.resume_location_recording, .none);
719 }
720
721 /// End the route and hand back everything it collected.
722 ///
723 /// No guard: `stopLocationRecording` has none in Swift either, so stopping
724 /// a recording that is not running answers the summary of a stopped
725 /// recording rather than an error. "There is no recording" and "the
726 /// recording is now stopped" are the same state to a caller.
727 ///
728 /// `id` and `startedAt` are deliberately **not** cleared, matching Swift —
729 /// which is why a stop-then-read in one launch still reports them, and a
730 /// relaunch does not.
731 fn stopRecording(self: *Self, data: []const u8) !void {
732 _ = data;
733 if (!is_darwin) return error.UnsupportedPlatform;
734
735 var snapshot = try snapshotRecording(self.allocator);
736 defer snapshot.deinit(self.allocator);
737
738 const next: locrec.State = .{
739 .active = false,
740 .paused = false,
741 .id = snapshot.id,
742 .started_at = snapshot.started_at,
743 };
744 try locrec.writeState(self.allocator, next);
745 try commitRecording(next, null);
746
747 stopUpdatesUnlessWatched("stopLocationRecording", .drop_background);
748
749 try self.replySummary(A.stop_location_recording, .with_locations);
750 }
751
752 /// The summary, plus how many samples are on disk.
753 fn recordingState(self: *Self, data: []const u8) !void {
754 _ = data;
755 try self.replySummary(A.get_location_recording_state, .with_sample_count);
756 }
757
758 /// What the four summary actions reply, in Swift's key order.
759 ///
760 /// `locationRecordingSummary()` is `id`, `active`, `paused`, `startedAt`;
761 /// `stopLocationRecording` adds `locations` and `getLocationRecordingState`
762 /// adds `sampleCount`, and no other action carries either.
763 fn replySummary(self: *Self, comptime action: []const u8, extra: SummaryExtra) !void {
764 var snapshot = try snapshotRecording(self.allocator);
765 defer snapshot.deinit(self.allocator);
766
767 var out: std.ArrayListUnmanaged(u8) = .empty;
768 defer out.deinit(self.allocator);
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 }
790
791 switch (extra) {
792 .none => {},
793 .with_locations => {
794 const locations = try locrec.readTrackArray(self.allocator);
795 defer self.allocator.free(locations);
796 try out.appendSlice(self.allocator, ",\"locations\":");
797 try out.appendSlice(self.allocator, locations);
798 },
799 .with_sample_count => {
800 const count = try locrec.sampleCount(self.allocator);
801 var buf: [32]u8 = undefined;
802 const rendered = try std.fmt.bufPrint(&buf, "{d}", .{count});
803 try out.appendSlice(self.allocator, ",\"sampleCount\":");
804 try out.appendSlice(self.allocator, rendered);
805 },
806 }
807
808 try out.append(self.allocator, '}');
809
810 bridge_error.sendResultToJS(self.allocator, action, out.items);
811 }
812};
813
814
815// =============================================================================
816// The recorder's shared machinery: the manager hand-off, the state commit, and
817// the two values Swift takes from Foundation.
818// =============================================================================
819
820/// Re-adopt a recording that outlived the last launch.
821///
822/// `restoreLocationRecordingState()`, moved to the runtime that owns the
823/// recorder. Swift calls this instead of its own restore, and the reason it has
824/// to is an ordering it cannot change: SwiftUI runs `makeCoordinator()` before
825/// `makeUIView`, so `Coordinator.init` — where the restore lives — happens
826/// before `CraftZigRuntime.attach` has told Zig anything at all. Whoever
827/// restores first owns the `CLLocationManager` for the rest of the launch, and
828/// a Zig-owned `stop` cannot stop a recording Swift restored into its own
829/// state. That ordering was the deferral's whole reason (#124); this is the
830/// hook that settles it.
831///
832/// Returns **true when Zig has taken responsibility for the recorder**, which
833/// is whenever this function ran at all — the caller's `dlsym` finding the
834/// symbol is itself the proof. It is deliberately not "a recording was found":
835/// a launch with no recording still leaves Zig owning the next `start`, and a
836/// Swift restore under it would be a second owner of the same file.
837///
838/// Not gated here. The call site is inside Swift's `if config.enableGeolocation`,
839/// exactly where `restoreLocationRecordingState()` was, so the flag is honoured
840/// by placement rather than by a second, drifting copy of the check.
841pub fn adoptRecording() bool {
842 if (!is_darwin) return false;
843
844 const allocator = recording_allocator;
845
846 var state = locrec.readState(allocator) catch |err| {
847 // The state file is the only record of a recording in progress, and
848 // Swift would be reading the same bytes. Reporting ownership anyway is
849 // the honest answer: falling back would not recover the route, it would
850 // just add a second reader of a file neither can parse.
851 std.log.warn(
852 "location recording: could not read the persisted state at launch ({}); no recording is adopted",
853 .{err},
854 );
855 return true;
856 };
857 defer state.deinit(allocator);
858
859 // `guard … state["active"] as? Bool == true else { return }` already
860 // applied by `readState`, so this is Swift's early return.
861 if (!state.active) return true;
862
863 const sels = Sels.resolve() catch |err| {
864 std.log.err("location recording: cannot adopt the recording at launch ({}); its samples will not resume", .{err});
865 return true;
866 };
867
868 commitRecording(state, sels) catch |err| {
869 std.log.err("location recording: cannot take the recording into memory at launch ({})", .{err});
870 return true;
871 };
872
873 // `if !isLocationRecordingPaused { … startUpdatingLocation() }`. A paused
874 // recording is restored into memory and left alone, so `resume` finds it.
875 if (state.paused) return true;
876
877 const authorization = resolveAuthorization() catch |err| {
878 std.log.err("location recording: adopted the recording but cannot resolve authorization ({}); it will not collect until resumed", .{err});
879 return true;
880 };
881 const updates = UpdateSelectors.resolve(authorization) catch |err| {
882 std.log.err("location recording: adopted the recording but cannot resolve its selectors ({})", .{err});
883 return true;
884 };
885 const background = backgroundLocationDeclared() catch false;
886 const mgr = ensureManager() catch |err| {
887 std.log.err("location recording: adopted the recording but cannot build a CLLocationManager ({})", .{err});
888 return true;
889 };
890
891 // `.skip`: Swift's restore does not re-request authorization, and a
892 // permission prompt at launch is not something the page asked for.
893 applyUpdates(mgr, updates, background, .skip);
894 return true;
895}
896
897/// What a summary reply carries beyond the four common keys.
898const SummaryExtra = enum { none, with_locations, with_sample_count };
899
900/// The selectors `startUpdatingLocation` needs, plus the authorization request
901/// that precedes it.
902///
903/// Resolved as a set before any state changes, so the sequence that actually
904/// turns GPS on cannot fail halfway. `watchPosition` and the two recorder
905/// actions that start collecting all run the same four lines; keeping one copy
906/// is how they stay the same four lines.
907const UpdateSelectors = struct {
908 authorize: Id,
909 start: Id,
910 responds: Id,
911 allows_background: Id,
912 shows_indicator: Id,
913
914 fn resolve(authorization: Authorization) !UpdateSelectors {
915 const auth_selector_name: [*:0]const u8 = switch (authorization) {
916 .always => "requestAlwaysAuthorization",
917 .when_in_use => "requestWhenInUseAuthorization",
918 };
919 return .{
920 .authorize = try selector(auth_selector_name),
921 .start = try selector("startUpdatingLocation"),
922 .responds = try selector("respondsToSelector:"),
923 .allows_background = try selector("setAllowsBackgroundLocationUpdates:"),
924 .shows_indicator = try selector("setShowsBackgroundLocationIndicator:"),
925 };
926 }
568927};
569928
929/// Request authorization, configure background updates, and start the stream.
930///
931/// Infallible by construction: every selector was resolved by
932/// `UpdateSelectors.resolve` while an error was still deliverable.
933/// Whether the authorization prompt is part of the sequence.
934///
935/// Every page-initiated start requests it; `restoreLocationRecordingState` does
936/// not, because a recording being re-adopted was authorized when it started and
937/// a prompt at launch would be one the page never asked for.
938const Authorize = enum { request, skip };
939
940fn applyUpdates(mgr: *anyopaque, sels: UpdateSelectors, background: bool, authorize: Authorize) void {
941 if (authorize == .request) objc.msgSend(mgr, sels.authorize);
942
943 if (background) {
944 // `configureBackgroundLocationIfNeeded()`, under the guard that makes
945 // it safe. Both properties take the same value in Swift, and both are
946 // set to it here.
947 if (!setBoolIfSupported(mgr, sels.responds, sels.allows_background, true)) {
948 std.log.warn(
949 "location: this CLLocationManager has no allowsBackgroundLocationUpdates; " ++
950 "updates will stop when the app is backgrounded even though UIBackgroundModes declares location",
951 .{},
952 );
953 }
954 // Purely the status-bar indicator. A manager without it is an OS too
955 // old for the property, not a broken subscription, so it is not worth a
956 // warning of its own.
957 _ = setBoolIfSupported(mgr, sels.responds, sels.shows_indicator, true);
958 }
959
960 objc.msgSend(mgr, sels.start);
961}
962
963/// Whether `stopUpdatingLocation` also clears `allowsBackgroundLocationUpdates`.
964///
965/// `pauseLocationRecording` stops the stream and leaves the property alone;
966/// `stopLocationRecording` and `stopWatchingPosition` clear it. Swift makes the
967/// same distinction, and it matters: a pause that dropped the property would
968/// need the app to be foregrounded to resume.
969const BackgroundOnStop = enum { keep_background, drop_background };
970
971/// Stop the shared manager, unless the watch is still using it.
972///
973/// The recorder's half of the arbitration `clearWatch` does in the other
974/// direction. Swift writes it as `if !isWatchingLocation { … }` in both
975/// `pauseLocationRecording` and `stopLocationRecording`.
976fn stopUpdatesUnlessWatched(comptime who: []const u8, mode: BackgroundOnStop) void {
977 if (!is_darwin) return;
978
979 if (watchHoldsTheManager()) {
980 std.log.info(
981 who ++ ": a watch is still using this manager; leaving it updating",
982 .{},
983 );
984 return;
985 }
986
987 const mgr = manager orelse return;
988
989 const sel_stop = selector("stopUpdatingLocation") catch return;
990 objc.msgSend(mgr, sel_stop);
991
992 if (mode == .drop_background) {
993 const sel_responds = selector("respondsToSelector:") catch return;
994 const sel_allows_background = selector("setAllowsBackgroundLocationUpdates:") catch return;
995 // Setting it to NO is safe in any process — only YES can raise.
996 _ = setBoolIfSupported(mgr, sel_responds, sel_allows_background, false);
997 }
998}
999
1000/// Replace the recorder's state and its subscription in one lock.
1001///
1002/// `next.id` is **copied**, not adopted: callers hold their own snapshot for
1003/// the reply, and an ownership hand-off across a mutex is the kind of detail
1004/// that reads fine and frees twice.
1005///
1006/// The subscription follows the state rather than being set separately —
1007/// collecting is exactly `active and !paused`, which is
1008/// `appendRecordedLocation`'s guard — so there is no arrangement of these two
1009/// variables in which the track and the summary disagree.
1010fn commitRecording(next: locrec.State, sels: ?Sels) !void {
1011 const collecting = next.active and !next.paused;
1012 std.debug.assert(!collecting or sels != null);
1013
1014 const copy: ?[]const u8 = if (next.id) |id| try recording_allocator.dupe(u8, id) else null;
1015 errdefer if (copy) |c| recording_allocator.free(c);
1016
1017 state_mutex.lock();
1018 defer state_mutex.unlock();
1019
1020 if (recording.id) |old| recording_allocator.free(old);
1021 recording = .{
1022 .active = next.active,
1023 .paused = next.paused,
1024 .id = copy,
1025 .started_at = next.started_at,
1026 };
1027 recording_sels = if (collecting) sels else null;
1028}
1029
1030/// A copy of the recorder's state that outlives the lock.
1031fn snapshotRecording(allocator: std.mem.Allocator) !locrec.State {
1032 state_mutex.lock();
1033 defer state_mutex.unlock();
1034
1035 return .{
1036 .active = recording.active,
1037 .paused = recording.paused,
1038 .id = if (recording.id) |id| try allocator.dupe(u8, id) else null,
1039 .started_at = recording.started_at,
1040 };
1041}
1042
1043/// `rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")`.
1044///
1045/// The text and the code are both lost on the way through `BridgeError`, which
1046/// carries an enum rather than a free-text channel — the same trade
1047/// `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document.
1048/// `InvalidParameter` is the migration notes' designated stand-in, so the page
1049/// gets `INVALID_PARAMETER` where Swift sent `NO_ACTIVE_RECORDING`. The
1050/// rejection itself, which is the part a promise needs, is not lost.
1051fn noActiveRecording() bridge_error.BridgeError {
1052 std.log.warn(
1053 "location recording: no active recording to pause or resume; rejecting, as Swift's NO_ACTIVE_RECORDING does",
1054 .{},
1055 );
1056 return bridge_error.BridgeError.InvalidParameter;
1057}
1058
1059/// `UUID().uuidString`, through `NSUUID` rather than a hand-rolled generator.
1060///
1061/// The id is opaque to the page — it is compared, not parsed — so what matters
1062/// is that it is unique and that it comes from the same source Swift's does.
1063/// Caller owns the result.
1064fn newRecordingId(allocator: std.mem.Allocator) ![]u8 {
1065 if (!is_darwin) return error.UnsupportedPlatform;
1066
1067 const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound;
1068 const sel_uuid = try selector("UUID");
1069 const uuid = objc.msgSendId(NSUUID, sel_uuid) orelse return error.NativeCallFailed;
1070
1071 const text = readNSString(uuid, "UUIDString") orelse return error.NativeCallFailed;
1072 return allocator.dupe(u8, text);
1073}
1074
1075/// `Date().timeIntervalSince1970 * 1000`.
1076///
1077/// Milliseconds since the epoch as an `f64`, which is what the page compares
1078/// against its own `Date.now()`. The wall clock, not the monotonic one, for the
1079/// same reason Swift's is: it is a timestamp, not a duration.
1080///
1081/// `compat.milliTimestamp` rather than `std.time.milliTimestamp`, which 0.17
1082/// removed — `bridge_mobile_biometric.zig` takes the same route. Swift's value
1083/// carries sub-millisecond precision that this drops; the page compares it
1084/// against `Date.now()`, whose resolution is coarser still.
1085fn nowMillis() f64 {
1086 return @floatFromInt(compat.milliTimestamp());
1087}
1088
5701089/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
5711090/// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes'
5721091/// designated stand-in, and the point is that the seventeenth concurrent call
@@ -1116,6 +1635,32 @@ var watch_sels: ?Sels = null;
11161635/// not a guard.
11171636var state_mutex: compat_mutex.Mutex = .{};
11181637
1638/// The recorder's four fields, kept where Swift keeps them.
1639///
1640/// `CraftWebView.Coordinator` holds `isRecordingLocation`,
1641/// `isLocationRecordingPaused`, `locationRecordingId` and
1642/// `locationRecordingStartedAt` in memory and mirrors them to disk on every
1643/// transition; the file is read back only at launch. Answering
1644/// `getLocationRecordingState` from memory rather than from the file is what
1645/// makes a stop-then-read in one launch report the stopped recording's id,
1646/// where a relaunch reports null — the divergence #124 records, reproduced here
1647/// by keeping Swift's arrangement instead of improving on it.
1648///
1649/// `id` is owned, allocated from `std.heap.c_allocator`, and freed whenever it
1650/// is replaced.
1651var recording: locrec.State = .{};
1652
1653/// Non-null exactly while fixes belong in the track: active, and not paused.
1654///
1655/// The same shape `watch_sels` uses — the presence of the selectors *is* the
1656/// subscription — so `didUpdateLocations` asks one question to learn both
1657/// whether to append and which names to shape with.
1658var recording_sels: ?Sels = null;
1659
1660/// The allocator behind `recording.id`. Module-level state outlives every
1661/// dispatch frame, so it cannot borrow a bridge's allocator.
1662const recording_allocator = std.heap.c_allocator;
1663
11191664/// Record the call the delegate will answer, returning whatever it displaced so
11201665/// the caller can reject that one rather than drop it.
11211666fn publishPendingFix(ticket: ios_async.Ticket, sels: Sels) ?PendingFix {
@@ -1152,6 +1697,11 @@ const DelegateWork = struct {
11521697 /// until `clearWatch` stops it. Clearing it here would deliver one event
11531698 /// and then go silent with the page's callback still registered.
11541699 watch: ?Sels,
1700 /// Read and not cleared, for the same reason: a recording takes every fix
1701 /// until it is paused or stopped. Null while paused, which is how
1702 /// `appendRecordedLocation`'s `guard isRecordingLocation && !isLocationRecordingPaused`
1703 /// is spelled here.
1704 recording: ?Sels,
11551705};
11561706
11571707fn takeDelegateWork() DelegateWork {
@@ -1159,7 +1709,26 @@ fn takeDelegateWork() DelegateWork {
11591709 defer state_mutex.unlock();
11601710 const call = pending_fix;
11611711 pending_fix = null;
1162 return .{ .pending = call, .watch = watch_sels };
1712 return .{ .pending = call, .watch = watch_sels, .recording = recording_sels };
1713}
1714
1715/// Whether a recording is collecting right now.
1716///
1717/// `stopWatchingPosition`'s `if !isRecordingLocation` guard, which is the reason
1718/// `clearWatch` cannot simply stop the manager any more: the recorder and the
1719/// watch share one `CLLocationManager`, so whichever stops second is the one
1720/// that stops it.
1721fn recordingHoldsTheManager() bool {
1722 state_mutex.lock();
1723 defer state_mutex.unlock();
1724 return recording_sels != null;
1725}
1726
1727/// Whether a watch is running, for the recorder's half of the same guard.
1728fn watchHoldsTheManager() bool {
1729 state_mutex.lock();
1730 defer state_mutex.unlock();
1731 return watch_sels != null;
11631732}
11641733
11651734/// What `clearWatch` is stopping.
@@ -1219,9 +1788,11 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void {
12191788 const sels = blk: {
12201789 if (work.pending) |call| break :blk call.sels;
12211790 if (work.watch) |watching| break :blk watching;
1791 if (work.recording) |collecting| break :blk collecting;
12221792 // 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", .{});
1793 // watch stopped. Nothing to reply to, nobody subscribed, and no
1794 // recording to write it to.
1795 std.log.info("location: a fix arrived with no call waiting, no watch running and no recording; ignored", .{});
12251796 return;
12261797 };
12271798
@@ -1246,7 +1817,25 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void {
12461817 // to both. A page reading `e.detail.accuracy` and a page reading
12471818 // `position.accuracy` are reading the same eight keys.
12481819 if (work.pending) |call| ios_async.deliverJson(call.ticket, json);
1249 if (work.watch != null) ios_events.emit(.location_update, json);
1820
1821 // Swift's order: `resolveCallback`, then `appendRecordedLocation(data)`,
1822 // then the event. The track gets the same bytes the event carries, because
1823 // Swift hands one dictionary to both — so a sample read back later is
1824 // exactly the fix the page saw live.
1825 //
1826 // A failed append is logged inside and dropped here, which is
1827 // `appendRecordedLocation`'s own `catch { print(…) }`. One lost sample must
1828 // not cost the watch its event or the caller its reply.
1829 if (work.recording != null) {
1830 locrec.appendSample(allocator, json) catch {};
1831 }
1832
1833 // `if isWatchingLocation || isRecordingLocation`, exactly. This is the
1834 // guard that made a recording emit `craftLocationUpdate` even with no watch
1835 // running — and, while the recorder lived in Swift and the watch lived
1836 // here, the guard that fired *twice* for a page doing both. One manager
1837 // again, one event per fix.
1838 if (work.watch != null or work.recording != null) ios_events.emit(.location_update, json);
12501839}
12511840
12521841fn didFailWithError(_: Id, _: Id, _: Id, err_object: Id) callconv(.c) void {
@@ -1427,10 +2016,15 @@ fn readNSInteger(object: Id, comptime name: [*:0]const u8) c_long {
14272016const testing = std.testing;
14282017
14292018test "the declared actions are the ones the handler serves" {
1430 try testing.expectEqual(@as(usize, 3), capability_actions.len);
2019 try testing.expectEqual(@as(usize, 8), capability_actions.len);
14312020 try testing.expectEqualStrings(A.get_current_position, capability_actions[0].name);
14322021 try testing.expectEqualStrings(A.watch_position, capability_actions[1].name);
14332022 try testing.expectEqualStrings(A.clear_watch, capability_actions[2].name);
2023 try testing.expectEqualStrings(A.start_location_recording, capability_actions[3].name);
2024 try testing.expectEqualStrings(A.pause_location_recording, capability_actions[4].name);
2025 try testing.expectEqualStrings(A.resume_location_recording, capability_actions[5].name);
2026 try testing.expectEqualStrings(A.stop_location_recording, capability_actions[6].name);
2027 try testing.expectEqualStrings(A.get_location_recording_state, capability_actions[7].name);
14342028
14352029 for (capability_actions) |decl| {
14362030 // A `.result` whose handler never replies parks the caller on an untimed
@@ -1438,7 +2032,8 @@ test "the declared actions are the ones the handler serves" {
14382032 // nothing. Both failure modes are invisible from the page. Swift
14392033 // resolves all three of these, so all three are `.result` — including
14402034 // `watchPosition`, whose `true` is about the subscribe having started,
1441 // not about a fix having arrived.
2035 // not about a fix having arrived, and the five recorder actions, each
2036 // of which ends in exactly one `resolveCallback` or `rejectCallback`.
14422037 try testing.expectEqual(capabilities.Reply.result, decl.reply);
14432038 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
14442039 // `.live` with a reason would be a contradiction the manifest shows to apps.
@@ -1561,15 +2156,12 @@ test "an action the namespace does not serve is reported, not ignored" {
15612156 bridge_error.BridgeError.UnknownAction,
15622157 bridge.handleMessage("getcurrentposition", "{}"),
15632158 );
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.
2159 // `readLocationRecording` is the one recording action that stays with
2160 // `bridge_mobile_locrecording.zig`: it reads the track and touches neither
2161 // the manager nor the lifecycle. Two modules answering one action would
2162 // make `ios_dispatch`'s first-match routing order-dependent, so the split
2163 // is asserted from this side as well as that one.
15672164 for ([_][]const u8{
1568 "startLocationRecording",
1569 "pauseLocationRecording",
1570 "resumeLocationRecording",
1571 "stopLocationRecording",
1572 "getLocationRecordingState",
15732165 "readLocationRecording",
15742166 }) |action| {
15752167 try testing.expectError(
packages/zig/src/bridge_mobile_locrecording.zigmodified+18-0
Changes to packages/zig/src/bridge_mobile_locrecording.zig
@@ -852,6 +852,24 @@ fn appendSampleTo(allocator: std.mem.Allocator, path: []const u8, sample_json: [
852852 };
853853}
854854
855/// Every sample as the bare JSON array `readLocationRecording` resolves.
856///
857/// `stopLocationRecording` puts this same array under a `locations` key
858/// (`CraftApp.swift:3264-3267`), so the two actions cannot disagree about which
859/// lines survive: they call the same reader.
860///
861/// Caller owns the result. A missing track is `[]`.
862pub fn readTrackArray(allocator: std.mem.Allocator) ![]u8 {
863 const path = try trackFilePath(allocator);
864 defer allocator.free(path);
865
866 const text = try readTrackBytes(allocator, path) orelse
867 return allocator.dupe(u8, empty_array);
868 defer allocator.free(text);
869
870 return shapeRecording(allocator, text);
871}
872
855873/// How many samples `readLocationRecording` would return.
856874///
857875/// `getLocationRecordingState` reports `loadRecordedLocations().count`
packages/zig/src/ios_config.zigmodified+6-0
Changes to packages/zig/src/ios_config.zig
@@ -448,6 +448,12 @@ pub fn gateFor(action: []const u8) ?Feature {
448448 .{ "takeScreenshot", .screen_capture },
449449 .{ "unlockOrientation", .orientation_lock },
450450 .{ "watchPosition", .geolocation },
451 // Only `startLocationRecording` of the six recording actions is gated
452 // in the spec (`CraftApp.swift:694-699`). Pausing, resuming, stopping
453 // and reading a route the app already recorded carry no guard there,
454 // and adding one here would strand a track on disk with no action able
455 // to reach it.
456 .{ "startLocationRecording", .geolocation },
451457 };
452458 inline for (table) |entry| {
453459 if (std.mem.eql(u8, action, entry[0])) return entry[1];
packages/zig/src/ios_dispatch.zigmodified+22-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -475,6 +475,28 @@ export fn craft_ios_set_webview(webview: ?*anyopaque) callconv(.c) void {
475475 setWebView(webview);
476476}
477477
478/// Take over a location recording that outlived the last launch.
479///
480/// The one thing Zig has to do at launch rather than on a page message, and the
481/// reason it needs an export of its own: SwiftUI builds the coordinator before
482/// it builds the view, so `CraftWebView.Coordinator.init` runs — and
483/// `restoreLocationRecordingState()` with it — before `craft_ios_set_webview`
484/// has been called even once. Whichever runtime restores first owns the
485/// `CLLocationManager` for the rest of the launch.
486///
487/// Swift calls this *instead of* its own restore, inside the same
488/// `config.enableGeolocation` guard, and falls back to
489/// `restoreLocationRecordingState()` only when `dlsym` does not find this
490/// symbol — which is exactly the app that has no Zig recorder to take over.
491///
492/// Returns true when Zig has taken responsibility, so the caller must not also
493/// restore. That is not the same as "a recording was found": an app with no
494/// recording in progress still has a Zig-owned recorder for the next `start`,
495/// and a Swift restore under it would be a second owner of one file.
496export fn craft_ios_adopt_location_recording() callconv(.c) bool {
497 return bridge_mobile_location.adoptRecording();
498}
499
478500/// Forget the webview, if it is still the one the caller installed.
479501///
480502/// The other half of `craft_ios_set_webview`, and the reason it is needed:
packages/zig/test/ios_conformance_test.zigmodified+2-11
Changes to packages/zig/test/ios_conformance_test.zig
@@ -121,7 +121,7 @@ const dispatch_end = "func webView(";
121121/// and `deliverErrorCode` landed the day after that was written. Worth
122122/// re-reading the other deferrals for the same reason before assuming they
123123/// still hold.
124const max_not_yet_migrated: usize = 23;
124const max_not_yet_migrated: usize = 18;
125125
126126fn dispatcherRegion() []const u8 {
127127 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";
@@ -305,15 +305,6 @@ const deliberate_deferrals = [_]Deferral{
305305 // name the spec is free to change.
306306 .{ .action = "registerPush", .reason = "the device token lands on a SwiftUI-owned app delegate; Zig could only observe a notification Swift posts" },
307307
308 // The recorder is a CLLocationManager plus two files that
309 // `CraftWebView.Coordinator.init` re-adopts at launch, so a Zig-owned stop
310 // cannot stop a recording Swift has already restored into its own state.
311 .{ .action = "startLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
312 .{ .action = "stopLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
313 .{ .action = "pauseLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
314 .{ .action = "resumeLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
315 .{ .action = "getLocationRecordingState", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" },
316
317308 // VisionKit's `DataScannerViewController` is Swift-only: the framework's
318309 // Objective-C headers carry DocumentCamera and nothing else, and the
319310 // delegate callback takes a `RecognizedItem`, a Swift enum with associated
@@ -356,7 +347,7 @@ test "every recorded deferral is real, and still a deferral" {
356347 }
357348
358349 // Non-vacuity: the loop above is satisfied by an empty table.
359 try testing.expect(deliberate_deferrals.len >= 23);
350 try testing.expect(deliberate_deferrals.len >= 18);
360351
361352 // As of this commit the table happens to cover every unmigrated action,
362353 // but that is not asserted. Pinning the exact count would make migrating a