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
packages/ios/templates/CraftApp.swiftmodified+30-1
Changes to packages/ios/templates/CraftApp.swift
@@ -457,7 +457,12 @@ struct CraftWebView: UIViewRepresentable {
457457 locationManager?.desiredAccuracy = kCLLocationAccuracyBest
458458 locationManager?.activityType = .fitness
459459 locationManager?.pausesLocationUpdatesAutomatically = false
460 restoreLocationRecordingState()
460 // Zig owns the recorder when it is linked, including the
461 // relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
462 // Running both would leave two managers appending to one track.
463 if !CraftZigRuntime.adoptLocationRecording() {
464 restoreLocationRecordingState()
465 }
461466 }
462467 if config.enableContacts {
463468 contactStore = CNContactStore()
@@ -5465,6 +5470,7 @@ final class CraftZigRuntime: NSObject {
54655470 private typealias HandleActionFn = @convention(c) (
54665471 UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
54675472 ) -> Bool
5473 private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool
54685474
54695475 private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
54705476
@@ -5483,6 +5489,11 @@ final class CraftZigRuntime: NSObject {
54835489 return unsafeBitCast(sym, to: HandleActionFn.self)
54845490 }()
54855491
5492 private static let adoptLocationRecordingFn: AdoptLocationRecordingFn? = {
5493 guard let sym = dlsym(image, "craft_ios_adopt_location_recording") else { return nil }
5494 return unsafeBitCast(sym, to: AdoptLocationRecordingFn.self)
5495 }()
5496
54865497 /// Whether this build has a Zig runtime at all. Read by nothing here; kept
54875498 /// because "is Zig linked" is the first question to ask when an action
54885499 /// behaves like the Swift one after a migration was supposed to move it.
@@ -5497,6 +5508,24 @@ final class CraftZigRuntime: NSObject {
54975508 setWebViewFn?(Unmanaged.passUnretained(webView).toOpaque())
54985509 }
54995510
5511 /// Hand a recording that outlived the last launch to whichever runtime owns
5512 /// the recorder.
5513 ///
5514 /// Returns true when Zig took it, and the caller must then *not* run
5515 /// `restoreLocationRecordingState()`. This is the one place the usual
5516 /// "offer it to Zig, fall back on false" pattern cannot be used through
5517 /// `handleAction`: there is no page message at launch, and the restore
5518 /// happens in `Coordinator.init` — before `attach`, because SwiftUI builds
5519 /// the coordinator before the view. Whoever restores first owns the
5520 /// `CLLocationManager` for the rest of the launch, so the decision has to
5521 /// be made here rather than at the first `stopLocationRecording`.
5522 ///
5523 /// False in a build with no Zig runtime, which is exactly when Swift's own
5524 /// restore is still the right thing to run.
5525 static func adoptLocationRecording() -> Bool {
5526 adoptLocationRecordingFn?() ?? false
5527 }
5528
55005529 /// Forget this webview, if Zig is still holding it.
55015530 ///
55025531 /// `attach` hands over an unretained pointer, so the moment SwiftUI
packages/zig/src/bridge_mobile_location.zigmodified+808-64
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
@@ -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,515 @@ 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 appendSummaryFields(self.allocator, &out, snapshot);
771
772 switch (extra) {
773 .none => {},
774 .with_locations => {
775 const locations = try locrec.readTrackArray(self.allocator);
776 defer self.allocator.free(locations);
777 try out.appendSlice(self.allocator, ",\"locations\":");
778 try out.appendSlice(self.allocator, locations);
779 },
780 .with_sample_count => {
781 const count = try locrec.sampleCount(self.allocator);
782 var buf: [32]u8 = undefined;
783 const rendered = try std.fmt.bufPrint(&buf, "{d}", .{count});
784 try out.appendSlice(self.allocator, ",\"sampleCount\":");
785 try out.appendSlice(self.allocator, rendered);
786 },
787 }
788
789 try out.append(self.allocator, '}');
790
791 bridge_error.sendResultToJS(self.allocator, action, out.items);
792 }
793};
794
795// =============================================================================
796// The recorder's shared machinery: the manager hand-off, the state commit, and
797// the two values Swift takes from Foundation.
798// =============================================================================
799
800/// Re-adopt a recording that outlived the last launch.
801///
802/// `restoreLocationRecordingState()`, moved to the runtime that owns the
803/// recorder. Swift calls this instead of its own restore, and the reason it has
804/// to is an ordering it cannot change: SwiftUI runs `makeCoordinator()` before
805/// `makeUIView`, so `Coordinator.init` — where the restore lives — happens
806/// before `CraftZigRuntime.attach` has told Zig anything at all. Whoever
807/// restores first owns the `CLLocationManager` for the rest of the launch, and
808/// a Zig-owned `stop` cannot stop a recording Swift restored into its own
809/// state. That ordering was the deferral's whole reason (#124); this is the
810/// hook that settles it.
811///
812/// Returns **true when Zig has taken responsibility for the recorder**, which
813/// is whenever this function ran at all — the caller's `dlsym` finding the
814/// symbol is itself the proof. It is deliberately not "a recording was found":
815/// a launch with no recording still leaves Zig owning the next `start`, and a
816/// Swift restore under it would be a second owner of the same file.
817///
818/// Not gated here. The call site is inside Swift's `if config.enableGeolocation`,
819/// exactly where `restoreLocationRecordingState()` was, so the flag is honoured
820/// by placement rather than by a second, drifting copy of the check.
821pub fn adoptRecording() bool {
822 if (!is_darwin) return false;
823
824 const allocator = recording_allocator;
825
826 var state = locrec.readState(allocator) catch |err| {
827 // The state file is the only record of a recording in progress, and
828 // Swift would be reading the same bytes. Reporting ownership anyway is
829 // the honest answer: falling back would not recover the route, it would
830 // just add a second reader of a file neither can parse.
831 std.log.warn(
832 "location recording: could not read the persisted state at launch ({}); no recording is adopted",
833 .{err},
834 );
835 return true;
836 };
837 defer state.deinit(allocator);
838
839 // `guard … state["active"] as? Bool == true else { return }` already
840 // applied by `readState`, so this is Swift's early return.
841 if (!state.active) return true;
842
843 const sels = Sels.resolve() catch |err| {
844 std.log.err("location recording: cannot adopt the recording at launch ({}); its samples will not resume", .{err});
845 return true;
846 };
847
848 commitRecording(state, sels) catch |err| {
849 std.log.err("location recording: cannot take the recording into memory at launch ({})", .{err});
850 return true;
851 };
852
853 // `if !isLocationRecordingPaused { … startUpdatingLocation() }`. A paused
854 // recording is restored into memory and left alone, so `resume` finds it.
855 if (state.paused) return true;
856
857 const authorization = resolveAuthorization() catch |err| {
858 std.log.err("location recording: adopted the recording but cannot resolve authorization ({}); it will not collect until resumed", .{err});
859 return true;
860 };
861 const updates = UpdateSelectors.resolve(authorization) catch |err| {
862 std.log.err("location recording: adopted the recording but cannot resolve its selectors ({})", .{err});
863 return true;
864 };
865 const background = backgroundLocationDeclared() catch false;
866 const mgr = ensureManager() catch |err| {
867 std.log.err("location recording: adopted the recording but cannot build a CLLocationManager ({})", .{err});
868 return true;
869 };
870
871 // `.skip`: Swift's restore does not re-request authorization, and a
872 // permission prompt at launch is not something the page asked for.
873 applyUpdates(mgr, updates, background, .skip);
874 return true;
875}
876
877/// `locationRecordingSummary()`: the four keys, in Swift's order.
878///
879/// Split out from the reply so the shape is pinnable without a
880/// `CLLocationManager` — the keys, their order, and the two that are `NSNull`
881/// on the wire rather than empty strings or zeroes.
882///
883/// Writes the opening brace and the four keys, and **leaves the object open**:
884/// `stopLocationRecording` and `getLocationRecordingState` each add one more
885/// key, and the caller closes it.
886fn appendSummaryFields(
887 allocator: std.mem.Allocator,
888 out: *std.ArrayListUnmanaged(u8),
889 state: locrec.State,
890) !void {
891 try out.appendSlice(allocator, "{\"id\":");
892 if (state.id) |id| {
893 // `appendJsonEscaped` writes no delimiters; the quotes are the caller's.
894 try out.append(allocator, '"');
895 try bridge_error.appendJsonEscaped(allocator, out, id);
896 try out.append(allocator, '"');
897 } else {
898 try out.appendSlice(allocator, "null");
899 }
900 try out.appendSlice(allocator, ",\"active\":");
901 try out.appendSlice(allocator, if (state.active) "true" else "false");
902 try out.appendSlice(allocator, ",\"paused\":");
903 try out.appendSlice(allocator, if (state.paused) "true" else "false");
904 try out.appendSlice(allocator, ",\"startedAt\":");
905 if (state.started_at) |started| {
906 const rendered = try std.fmt.allocPrint(allocator, "{d}", .{started});
907 defer allocator.free(rendered);
908 try out.appendSlice(allocator, rendered);
909 } else {
910 try out.appendSlice(allocator, "null");
911 }
912}
913
914/// What a summary reply carries beyond the four common keys.
915const SummaryExtra = enum { none, with_locations, with_sample_count };
916
917/// The selectors `startUpdatingLocation` needs, plus the authorization request
918/// that precedes it.
919///
920/// Resolved as a set before any state changes, so the sequence that actually
921/// turns GPS on cannot fail halfway. `watchPosition` and the two recorder
922/// actions that start collecting all run the same four lines; keeping one copy
923/// is how they stay the same four lines.
924const UpdateSelectors = struct {
925 authorize: Id,
926 start: Id,
927 responds: Id,
928 allows_background: Id,
929 shows_indicator: Id,
930
931 fn resolve(authorization: Authorization) !UpdateSelectors {
932 const auth_selector_name: [*:0]const u8 = switch (authorization) {
933 .always => "requestAlwaysAuthorization",
934 .when_in_use => "requestWhenInUseAuthorization",
935 };
936 return .{
937 .authorize = try selector(auth_selector_name),
938 .start = try selector("startUpdatingLocation"),
939 .responds = try selector("respondsToSelector:"),
940 .allows_background = try selector("setAllowsBackgroundLocationUpdates:"),
941 .shows_indicator = try selector("setShowsBackgroundLocationIndicator:"),
942 };
943 }
568944};
569945
946/// Request authorization, configure background updates, and start the stream.
947///
948/// Infallible by construction: every selector was resolved by
949/// `UpdateSelectors.resolve` while an error was still deliverable.
950/// Whether the authorization prompt is part of the sequence.
951///
952/// Every page-initiated start requests it; `restoreLocationRecordingState` does
953/// not, because a recording being re-adopted was authorized when it started and
954/// a prompt at launch would be one the page never asked for.
955const Authorize = enum { request, skip };
956
957fn applyUpdates(mgr: *anyopaque, sels: UpdateSelectors, background: bool, authorize: Authorize) void {
958 if (authorize == .request) objc.msgSend(mgr, sels.authorize);
959
960 if (background) {
961 // `configureBackgroundLocationIfNeeded()`, under the guard that makes
962 // it safe. Both properties take the same value in Swift, and both are
963 // set to it here.
964 if (!setBoolIfSupported(mgr, sels.responds, sels.allows_background, true)) {
965 std.log.warn(
966 "location: this CLLocationManager has no allowsBackgroundLocationUpdates; " ++
967 "updates will stop when the app is backgrounded even though UIBackgroundModes declares location",
968 .{},
969 );
970 }
971 // Purely the status-bar indicator. A manager without it is an OS too
972 // old for the property, not a broken subscription, so it is not worth a
973 // warning of its own.
974 _ = setBoolIfSupported(mgr, sels.responds, sels.shows_indicator, true);
975 }
976
977 objc.msgSend(mgr, sels.start);
978}
979
980/// Whether `stopUpdatingLocation` also clears `allowsBackgroundLocationUpdates`.
981///
982/// `pauseLocationRecording` stops the stream and leaves the property alone;
983/// `stopLocationRecording` and `stopWatchingPosition` clear it. Swift makes the
984/// same distinction, and it matters: a pause that dropped the property would
985/// need the app to be foregrounded to resume.
986const BackgroundOnStop = enum { keep_background, drop_background };
987
988/// Stop the shared manager, unless the watch is still using it.
989///
990/// The recorder's half of the arbitration `clearWatch` does in the other
991/// direction. Swift writes it as `if !isWatchingLocation { … }` in both
992/// `pauseLocationRecording` and `stopLocationRecording`.
993fn stopUpdatesUnlessWatched(comptime who: []const u8, mode: BackgroundOnStop) void {
994 if (!is_darwin) return;
995
996 if (watchHoldsTheManager()) {
997 std.log.info(
998 who ++ ": a watch is still using this manager; leaving it updating",
999 .{},
1000 );
1001 return;
1002 }
1003
1004 const mgr = manager orelse return;
1005
1006 const sel_stop = selector("stopUpdatingLocation") catch return;
1007 objc.msgSend(mgr, sel_stop);
1008
1009 if (mode == .drop_background) {
1010 const sel_responds = selector("respondsToSelector:") catch return;
1011 const sel_allows_background = selector("setAllowsBackgroundLocationUpdates:") catch return;
1012 // Setting it to NO is safe in any process — only YES can raise.
1013 _ = setBoolIfSupported(mgr, sel_responds, sel_allows_background, false);
1014 }
1015}
1016
1017/// Replace the recorder's state and its subscription in one lock.
1018///
1019/// `next.id` is **copied**, not adopted: callers hold their own snapshot for
1020/// the reply, and an ownership hand-off across a mutex is the kind of detail
1021/// that reads fine and frees twice.
1022///
1023/// The subscription follows the state rather than being set separately —
1024/// collecting is exactly `active and !paused`, which is
1025/// `appendRecordedLocation`'s guard — so there is no arrangement of these two
1026/// variables in which the track and the summary disagree.
1027fn commitRecording(next: locrec.State, sels: ?Sels) !void {
1028 const collecting = next.active and !next.paused;
1029 std.debug.assert(!collecting or sels != null);
1030
1031 const copy: ?[]const u8 = if (next.id) |id| try recording_allocator.dupe(u8, id) else null;
1032 errdefer if (copy) |c| recording_allocator.free(c);
1033
1034 state_mutex.lock();
1035 defer state_mutex.unlock();
1036
1037 if (recording.id) |old| recording_allocator.free(old);
1038 recording = .{
1039 .active = next.active,
1040 .paused = next.paused,
1041 .id = copy,
1042 .started_at = next.started_at,
1043 };
1044 recording_sels = if (collecting) sels else null;
1045}
1046
1047/// A copy of the recorder's state that outlives the lock.
1048fn snapshotRecording(allocator: std.mem.Allocator) !locrec.State {
1049 state_mutex.lock();
1050 defer state_mutex.unlock();
1051
1052 return .{
1053 .active = recording.active,
1054 .paused = recording.paused,
1055 .id = if (recording.id) |id| try allocator.dupe(u8, id) else null,
1056 .started_at = recording.started_at,
1057 };
1058}
1059
1060/// `rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")`.
1061///
1062/// The text and the code are both lost on the way through `BridgeError`, which
1063/// carries an enum rather than a free-text channel — the same trade
1064/// `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document.
1065/// `InvalidParameter` is the migration notes' designated stand-in, so the page
1066/// gets `INVALID_PARAMETER` where Swift sent `NO_ACTIVE_RECORDING`. The
1067/// rejection itself, which is the part a promise needs, is not lost.
1068fn noActiveRecording() bridge_error.BridgeError {
1069 std.log.warn(
1070 "location recording: no active recording to pause or resume; rejecting, as Swift's NO_ACTIVE_RECORDING does",
1071 .{},
1072 );
1073 return bridge_error.BridgeError.InvalidParameter;
1074}
1075
1076/// `UUID().uuidString`, through `NSUUID` rather than a hand-rolled generator.
1077///
1078/// The id is opaque to the page — it is compared, not parsed — so what matters
1079/// is that it is unique and that it comes from the same source Swift's does.
1080/// Caller owns the result.
1081fn newRecordingId(allocator: std.mem.Allocator) ![]u8 {
1082 if (!is_darwin) return error.UnsupportedPlatform;
1083
1084 const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound;
1085 const sel_uuid = try selector("UUID");
1086 const uuid = objc.msgSendId(NSUUID, sel_uuid) orelse return error.NativeCallFailed;
1087
1088 const text = readNSString(uuid, "UUIDString") orelse return error.NativeCallFailed;
1089 return allocator.dupe(u8, text);
1090}
1091
1092/// `Date().timeIntervalSince1970 * 1000`.
1093///
1094/// Milliseconds since the epoch as an `f64`, which is what the page compares
1095/// against its own `Date.now()`. The wall clock, not the monotonic one, for the
1096/// same reason Swift's is: it is a timestamp, not a duration.
1097///
1098/// `compat.milliTimestamp` rather than `std.time.milliTimestamp`, which 0.17
1099/// removed — `bridge_mobile_biometric.zig` takes the same route. Swift's value
1100/// carries sub-millisecond precision that this drops; the page compares it
1101/// against `Date.now()`, whose resolution is coarser still.
1102fn nowMillis() f64 {
1103 return @floatFromInt(compat.milliTimestamp());
1104}
1105
5701106/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
5711107/// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes'
5721108/// designated stand-in, and the point is that the seventeenth concurrent call
@@ -1116,6 +1652,32 @@ var watch_sels: ?Sels = null;
11161652/// not a guard.
11171653var state_mutex: compat_mutex.Mutex = .{};
11181654
1655/// The recorder's four fields, kept where Swift keeps them.
1656///
1657/// `CraftWebView.Coordinator` holds `isRecordingLocation`,
1658/// `isLocationRecordingPaused`, `locationRecordingId` and
1659/// `locationRecordingStartedAt` in memory and mirrors them to disk on every
1660/// transition; the file is read back only at launch. Answering
1661/// `getLocationRecordingState` from memory rather than from the file is what
1662/// makes a stop-then-read in one launch report the stopped recording's id,
1663/// where a relaunch reports null — the divergence #124 records, reproduced here
1664/// by keeping Swift's arrangement instead of improving on it.
1665///
1666/// `id` is owned, allocated from `std.heap.c_allocator`, and freed whenever it
1667/// is replaced.
1668var recording: locrec.State = .{};
1669
1670/// Non-null exactly while fixes belong in the track: active, and not paused.
1671///
1672/// The same shape `watch_sels` uses — the presence of the selectors *is* the
1673/// subscription — so `didUpdateLocations` asks one question to learn both
1674/// whether to append and which names to shape with.
1675var recording_sels: ?Sels = null;
1676
1677/// The allocator behind `recording.id`. Module-level state outlives every
1678/// dispatch frame, so it cannot borrow a bridge's allocator.
1679const recording_allocator = std.heap.c_allocator;
1680
11191681/// Record the call the delegate will answer, returning whatever it displaced so
11201682/// the caller can reject that one rather than drop it.
11211683fn publishPendingFix(ticket: ios_async.Ticket, sels: Sels) ?PendingFix {
@@ -1152,6 +1714,11 @@ const DelegateWork = struct {
11521714 /// until `clearWatch` stops it. Clearing it here would deliver one event
11531715 /// and then go silent with the page's callback still registered.
11541716 watch: ?Sels,
1717 /// Read and not cleared, for the same reason: a recording takes every fix
1718 /// until it is paused or stopped. Null while paused, which is how
1719 /// `appendRecordedLocation`'s `guard isRecordingLocation && !isLocationRecordingPaused`
1720 /// is spelled here.
1721 recording: ?Sels,
11551722};
11561723
11571724fn takeDelegateWork() DelegateWork {
@@ -1159,7 +1726,26 @@ fn takeDelegateWork() DelegateWork {
11591726 defer state_mutex.unlock();
11601727 const call = pending_fix;
11611728 pending_fix = null;
1162 return .{ .pending = call, .watch = watch_sels };
1729 return .{ .pending = call, .watch = watch_sels, .recording = recording_sels };
1730}
1731
1732/// Whether a recording is collecting right now.
1733///
1734/// `stopWatchingPosition`'s `if !isRecordingLocation` guard, which is the reason
1735/// `clearWatch` cannot simply stop the manager any more: the recorder and the
1736/// watch share one `CLLocationManager`, so whichever stops second is the one
1737/// that stops it.
1738fn recordingHoldsTheManager() bool {
1739 state_mutex.lock();
1740 defer state_mutex.unlock();
1741 return recording_sels != null;
1742}
1743
1744/// Whether a watch is running, for the recorder's half of the same guard.
1745fn watchHoldsTheManager() bool {
1746 state_mutex.lock();
1747 defer state_mutex.unlock();
1748 return watch_sels != null;
11631749}
11641750
11651751/// What `clearWatch` is stopping.
@@ -1219,9 +1805,11 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void {
12191805 const sels = blk: {
12201806 if (work.pending) |call| break :blk call.sels;
12211807 if (work.watch) |watching| break :blk watching;
1808 if (work.recording) |collecting| break :blk collecting;
12221809 // 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", .{});
1810 // watch stopped. Nothing to reply to, nobody subscribed, and no
1811 // recording to write it to.
1812 std.log.info("location: a fix arrived with no call waiting, no watch running and no recording; ignored", .{});
12251813 return;
12261814 };
12271815
@@ -1246,7 +1834,25 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void {
12461834 // to both. A page reading `e.detail.accuracy` and a page reading
12471835 // `position.accuracy` are reading the same eight keys.
12481836 if (work.pending) |call| ios_async.deliverJson(call.ticket, json);
1249 if (work.watch != null) ios_events.emit(.location_update, json);
1837
1838 // Swift's order: `resolveCallback`, then `appendRecordedLocation(data)`,
1839 // then the event. The track gets the same bytes the event carries, because
1840 // Swift hands one dictionary to both — so a sample read back later is
1841 // exactly the fix the page saw live.
1842 //
1843 // A failed append is logged inside and dropped here, which is
1844 // `appendRecordedLocation`'s own `catch { print(…) }`. One lost sample must
1845 // not cost the watch its event or the caller its reply.
1846 if (work.recording != null) {
1847 locrec.appendSample(allocator, json) catch {};
1848 }
1849
1850 // `if isWatchingLocation || isRecordingLocation`, exactly. This is the
1851 // guard that made a recording emit `craftLocationUpdate` even with no watch
1852 // running — and, while the recorder lived in Swift and the watch lived
1853 // here, the guard that fired *twice* for a page doing both. One manager
1854 // again, one event per fix.
1855 if (work.watch != null or work.recording != null) ios_events.emit(.location_update, json);
12501856}
12511857
12521858fn didFailWithError(_: Id, _: Id, _: Id, err_object: Id) callconv(.c) void {
@@ -1427,10 +2033,15 @@ fn readNSInteger(object: Id, comptime name: [*:0]const u8) c_long {
14272033const testing = std.testing;
14282034
14292035test "the declared actions are the ones the handler serves" {
1430 try testing.expectEqual(@as(usize, 3), capability_actions.len);
2036 try testing.expectEqual(@as(usize, 8), capability_actions.len);
14312037 try testing.expectEqualStrings(A.get_current_position, capability_actions[0].name);
14322038 try testing.expectEqualStrings(A.watch_position, capability_actions[1].name);
14332039 try testing.expectEqualStrings(A.clear_watch, capability_actions[2].name);
2040 try testing.expectEqualStrings(A.start_location_recording, capability_actions[3].name);
2041 try testing.expectEqualStrings(A.pause_location_recording, capability_actions[4].name);
2042 try testing.expectEqualStrings(A.resume_location_recording, capability_actions[5].name);
2043 try testing.expectEqualStrings(A.stop_location_recording, capability_actions[6].name);
2044 try testing.expectEqualStrings(A.get_location_recording_state, capability_actions[7].name);
14342045
14352046 for (capability_actions) |decl| {
14362047 // A `.result` whose handler never replies parks the caller on an untimed
@@ -1438,7 +2049,8 @@ test "the declared actions are the ones the handler serves" {
14382049 // nothing. Both failure modes are invisible from the page. Swift
14392050 // resolves all three of these, so all three are `.result` — including
14402051 // `watchPosition`, whose `true` is about the subscribe having started,
1441 // not about a fix having arrived.
2052 // not about a fix having arrived, and the five recorder actions, each
2053 // of which ends in exactly one `resolveCallback` or `rejectCallback`.
14422054 try testing.expectEqual(capabilities.Reply.result, decl.reply);
14432055 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
14442056 // `.live` with a reason would be a contradiction the manifest shows to apps.
@@ -1561,15 +2173,12 @@ test "an action the namespace does not serve is reported, not ignored" {
15612173 bridge_error.BridgeError.UnknownAction,
15622174 bridge.handleMessage("getcurrentposition", "{}"),
15632175 );
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.
2176 // `readLocationRecording` is the one recording action that stays with
2177 // `bridge_mobile_locrecording.zig`: it reads the track and touches neither
2178 // the manager nor the lifecycle. Two modules answering one action would
2179 // make `ios_dispatch`'s first-match routing order-dependent, so the split
2180 // is asserted from this side as well as that one.
15672181 for ([_][]const u8{
1568 "startLocationRecording",
1569 "pauseLocationRecording",
1570 "resumeLocationRecording",
1571 "stopLocationRecording",
1572 "getLocationRecordingState",
15732182 "readLocationRecording",
15742183 }) |action| {
15752184 try testing.expectError(
@@ -2266,3 +2875,138 @@ test "the delegate class registers under its own name, idempotently" {
22662875 return error.SelectorNotFound;
22672876 try testing.expect(!responds(first, sel_responds, sel_auth));
22682877}
2878
2879// =============================================================================
2880// The recorder.
2881//
2882// Two claims are worth pinning without a CLLocationManager: the summary's
2883// shape, which is what every one of the five replies with, and the arbitration
2884// between the watch and the recorder, which is the pair of guards that stop one
2885// from ending the other's stream.
2886// =============================================================================
2887
2888/// Leave the module-level recorder state as the tests found it.
2889///
2890/// These tests mutate process-wide state that the delegate also reads, so each
2891/// one puts it back — an escaped `active` recording would make a later test's
2892/// `didUpdateLocations` try to append to a track that is not there.
2893fn resetRecordingForTest() void {
2894 commitRecording(.{}, null) catch {};
2895 state_mutex.lock();
2896 defer state_mutex.unlock();
2897 watch_sels = null;
2898}
2899
2900test "the summary is the four keys in Swift's order" {
2901 var out: std.ArrayListUnmanaged(u8) = .empty;
2902 defer out.deinit(testing.allocator);
2903
2904 try appendSummaryFields(testing.allocator, &out, .{
2905 .active = true,
2906 .paused = false,
2907 .id = "ABC-123",
2908 .started_at = 1756900000000,
2909 });
2910
2911 // No closing brace: `stopLocationRecording` and `getLocationRecordingState`
2912 // add a fifth key after these four, so the caller closes the object.
2913 try testing.expectEqualStrings(
2914 \\{"id":"ABC-123","active":true,"paused":false,"startedAt":1756900000000
2915 , out.items);
2916}
2917
2918test "a recording that never started reports nulls, not empty strings or zeroes" {
2919 var out: std.ArrayListUnmanaged(u8) = .empty;
2920 defer out.deinit(testing.allocator);
2921
2922 // `locationRecordingSummary()` puts `NSNull` in both slots, and
2923 // `resolveCallback` serialises that as `null`. A page testing
2924 // `state.id === null` has to keep working.
2925 try appendSummaryFields(testing.allocator, &out, .{});
2926
2927 try testing.expectEqualStrings(
2928 \\{"id":null,"active":false,"paused":false,"startedAt":null
2929 , out.items);
2930}
2931
2932test "the subscription follows the state, so the track and the summary cannot disagree" {
2933 defer resetRecordingForTest();
2934
2935 // Collecting is exactly `active and !paused` — `appendRecordedLocation`'s
2936 // own guard — and `recording_sels` is how `didUpdateLocations` reads it.
2937 const sels = Sels.resolve() catch return error.SkipZigTest;
2938
2939 try commitRecording(.{ .active = true, .paused = false, .id = "r1" }, sels);
2940 try testing.expect(recordingHoldsTheManager());
2941
2942 try commitRecording(.{ .active = true, .paused = true, .id = "r1" }, null);
2943 try testing.expect(!recordingHoldsTheManager());
2944
2945 try commitRecording(.{ .active = false, .paused = false, .id = "r1" }, null);
2946 try testing.expect(!recordingHoldsTheManager());
2947}
2948
2949test "a stop keeps the id and startedAt, which is why a relaunch differs from a stop" {
2950 defer resetRecordingForTest();
2951
2952 const sels = Sels.resolve() catch return error.SkipZigTest;
2953 try commitRecording(.{ .active = true, .id = "r1", .started_at = 42 }, sels);
2954
2955 // `stopLocationRecording` clears neither, so a read in the same launch
2956 // still reports them. Only a relaunch — where `readState`'s guard runs —
2957 // answers null. The two halves of #124's third blocker, from this side.
2958 try commitRecording(.{ .active = false, .id = "r1", .started_at = 42 }, null);
2959
2960 var snapshot = try snapshotRecording(testing.allocator);
2961 defer snapshot.deinit(testing.allocator);
2962
2963 try testing.expect(!snapshot.active);
2964 try testing.expectEqualStrings("r1", snapshot.id.?);
2965 try testing.expectEqual(@as(f64, 42), snapshot.started_at.?);
2966}
2967
2968test "the watch and the recorder each hold the shared manager against the other" {
2969 defer resetRecordingForTest();
2970
2971 const sels = Sels.resolve() catch return error.SkipZigTest;
2972
2973 // `stopWatchingPosition`'s `if !isRecordingLocation`, and its mirror in
2974 // `pauseLocationRecording` / `stopLocationRecording`. Before the recorder
2975 // moved here neither guard could fail, because the two runtimes owned
2976 // different managers; now one manager serves both and whichever finishes
2977 // second must not end the other's stream.
2978 try testing.expect(!recordingHoldsTheManager());
2979 try testing.expect(!watchHoldsTheManager());
2980
2981 try commitRecording(.{ .active = true, .id = "r1" }, sels);
2982 publishWatch(sels);
2983 try testing.expect(recordingHoldsTheManager());
2984 try testing.expect(watchHoldsTheManager());
2985
2986 // The watch stops: the recorder is still collecting, so the manager stays.
2987 _ = takeWatchAndPending();
2988 try testing.expect(!watchHoldsTheManager());
2989 try testing.expect(recordingHoldsTheManager());
2990
2991 // And the recorder stops last, with nothing left holding it.
2992 try commitRecording(.{ .active = false, .id = "r1" }, null);
2993 try testing.expect(!recordingHoldsTheManager());
2994}
2995
2996test "a paused recording is not a consumer, so its fixes are not written" {
2997 defer resetRecordingForTest();
2998
2999 const sels = Sels.resolve() catch return error.SkipZigTest;
3000 try commitRecording(.{ .active = true, .paused = true, .id = "r1" }, null);
3001
3002 // `didUpdateLocations` reads one thing to decide both whether to append and
3003 // which names to shape with. A paused recording must not appear in it.
3004 const work = takeDelegateWork();
3005 try testing.expect(work.recording == null);
3006 try testing.expect(work.watch == null);
3007 try testing.expect(work.pending == null);
3008
3009 // And resuming puts it back.
3010 try commitRecording(.{ .active = true, .paused = false, .id = "r1" }, sels);
3011 try testing.expect(takeDelegateWork().recording != null);
3012}
packages/zig/src/bridge_mobile_locrecording.zigmodified+785-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};
@@ -588,6 +564,503 @@ fn readTrackBytes(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
588564 return buf;
589565}
590566
567// =============================================================================
568// The on-disk format of a recording.
569//
570// `bridge_mobile_location.zig` owns the `CLLocationManager`, the delegate and
571// the lifecycle; it calls in here for every byte that reaches the disk. The
572// dependency runs one way — nothing in this file imports that one — because a
573// recording really is what `CraftWebView.Coordinator` makes it: a flag on the
574// shared delegate plus two files. This is the half with a format to get wrong.
575//
576// Both files keep Swift's names, Swift's location and Swift's key spellings, so
577// a build with the Zig recorder removed reads back a recording this one wrote.
578// That is not hypothetical tidiness: `restoreLocationRecordingState` is still
579// in the template and still runs whenever the adoption export is absent.
580// =============================================================================
581
582/// The lifecycle mirror, beside the track. `CraftApp.swift:3156-3159`.
583const state_file_name = "craft-location-recording-state.json";
584
585/// The absolute path of `<AppSupport>/craft-location-recording-state.json`.
586fn stateFilePath(allocator: std.mem.Allocator) ![]u8 {
587 const directory = try applicationSupportPath(allocator);
588 defer allocator.free(directory);
589
590 return std.fmt.allocPrint(allocator, "{s}/" ++ state_file_name, .{directory});
591}
592
593/// The largest state file worth reading. The document is four scalars; anything
594/// approaching this is not a state file, and reading it into memory to discover
595/// that is the wrong order.
596const max_state_bytes: u64 = 64 * 1024;
597
598/// What `persistLocationRecordingState` writes and `restoreLocationRecordingState`
599/// reads back, with the same four keys and the same types.
600///
601/// `started_at` is milliseconds — `Date().timeIntervalSince1970 * 1000`
602/// (`CraftApp.swift:3222`) — and stays an `f64` the whole way, because that is
603/// what Swift wrote and rounding it to an integer here would change a value the
604/// page compares against its own `Date.now()`.
605pub const State = struct {
606 active: bool = false,
607 paused: bool = false,
608 /// Owned by the `State`. Null is `NSNull` on the wire, which is what a
609 /// recording that has never started has.
610 id: ?[]const u8 = null,
611 started_at: ?f64 = null,
612
613 pub fn deinit(self: *State, allocator: std.mem.Allocator) void {
614 if (self.id) |id| allocator.free(id);
615 self.id = null;
616 }
617};
618
619/// Read the persisted state, applying `restoreLocationRecordingState`'s own guard.
620///
621/// Swift opens with
622/// `guard … state["active"] as? Bool == true else { return }`
623/// (`CraftApp.swift:3197-3201`), and every `return` there leaves the four
624/// instance variables at their defaults. So a file that says `active:false` —
625/// which is exactly what a *stopped* recording leaves behind, `id` and
626/// `startedAt` still populated — restores as nothing at all.
627///
628/// That guard is the whole reason this returns a `State` rather than the file's
629/// contents. A reader that handed back the stopped recording's `id` would
630/// answer `{"id":"ABC-…"}` where Swift answers `{"id":null}`, which was one of
631/// the three blockers recorded against migrating this recorder (#124). It is a
632/// rule, and this is the rule, ported rather than approximated.
633///
634/// A missing file is `.{}`, not an error: there has never been a recording.
635pub fn readState(allocator: std.mem.Allocator) !State {
636 const path = try stateFilePath(allocator);
637 defer allocator.free(path);
638 return readStateFrom(allocator, path);
639}
640
641/// `readState` against a named file, so the guard above is testable without a
642/// test writing into `~/Library/Application Support`.
643fn readStateFrom(allocator: std.mem.Allocator, path: []const u8) !State {
644 const bytes = (try readSmallFile(allocator, path, max_state_bytes)) orelse return .{};
645 defer allocator.free(bytes);
646
647 // `JSONSerialization.jsonObject` throwing, or the cast to `[String: Any]`
648 // failing, both land on Swift's `else { return }`. Unreadable state is no
649 // state, not an error — the recording is gone either way, and saying so
650 // beats refusing every later call because one file is torn.
651 return decodeState(allocator, bytes) catch |err| {
652 std.log.warn(
653 "location recording: state file at '{s}' is unreadable ({}); treating it as no recording, exactly as restoreLocationRecordingState's guard does",
654 .{ path, err },
655 );
656 return .{};
657 };
658}
659
660/// The guard and the four reads, with no file anywhere near them.
661fn decodeState(allocator: std.mem.Allocator, bytes: []const u8) !State {
662 var parsed = std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}) catch {
663 return .{};
664 };
665 defer parsed.deinit();
666
667 const object = switch (parsed.value) {
668 .object => |o| o,
669 else => return .{},
670 };
671
672 // `as? Bool == true`: a missing key, a null, a number, a string all fail
673 // the cast and land on the same `return`.
674 const active = switch (object.get("active") orelse return .{}) {
675 .bool => |b| b,
676 else => return .{},
677 };
678 if (!active) return .{};
679
680 var state: State = .{ .active = true };
681 errdefer state.deinit(allocator);
682
683 // Past the guard, Swift's reads are `as? Bool ?? false` and `as? String` /
684 // `as? TimeInterval`, so a wrong type here is a default rather than a
685 // refusal.
686 if (object.get("paused")) |value| {
687 state.paused = switch (value) {
688 .bool => |b| b,
689 else => false,
690 };
691 }
692 if (object.get("id")) |value| {
693 switch (value) {
694 .string => |s| state.id = try allocator.dupe(u8, s),
695 else => {},
696 }
697 }
698 if (object.get("startedAt")) |value| {
699 state.started_at = switch (value) {
700 .float => |f| f,
701 .integer => |i| @floatFromInt(i),
702 else => null,
703 };
704 }
705
706 return state;
707}
708
709/// Mirror the lifecycle to disk, atomically, then protect the file.
710///
711/// `persistLocationRecordingState` (`CraftApp.swift:3179-3195`) writes all four
712/// keys every time, with `.atomic`, and then re-applies protection. Each of
713/// those three is load-bearing and each is done here.
714///
715/// Unlike Swift, a failure is **returned**. Swift catches and prints, so memory
716/// and file drift with nothing to signal it — a divergence its own recorder
717/// comment calls out. A caller here can refuse the action instead of reporting
718/// a recording that was never persisted.
719pub fn writeState(allocator: std.mem.Allocator, state: State) !void {
720 const path = try stateFilePath(allocator);
721 defer allocator.free(path);
722
723 const bytes = try encodeState(allocator, state);
724 defer allocator.free(bytes);
725
726 try writeFileAtomic(allocator, path, bytes);
727 protectFile(path);
728}
729
730/// The four keys, in `persistLocationRecordingState`'s spelling.
731fn encodeState(allocator: std.mem.Allocator, state: State) ![]u8 {
732 var out: std.ArrayList(u8) = .empty;
733 defer out.deinit(allocator);
734
735 try out.appendSlice(allocator, "{\"active\":");
736 try out.appendSlice(allocator, if (state.active) "true" else "false");
737 try out.appendSlice(allocator, ",\"paused\":");
738 try out.appendSlice(allocator, if (state.paused) "true" else "false");
739 try out.appendSlice(allocator, ",\"id\":");
740 if (state.id) |id| {
741 // `appendJsonEscaped` escapes the contents and writes no delimiters, so
742 // the quotes are this caller's job. Without them an id containing a
743 // quote does not merely look wrong — it tears the document, and the
744 // whole recording reads back as none.
745 try out.append(allocator, '"');
746 try bridge_error.appendJsonEscaped(allocator, &out, id);
747 try out.append(allocator, '"');
748 } else {
749 try out.appendSlice(allocator, "null");
750 }
751 try out.appendSlice(allocator, ",\"startedAt\":");
752 if (state.started_at) |started| {
753 // `allocPrint`, not a stack buffer: an `f64` rendered with `{d}` can
754 // need 347 bytes, and a `NoSpaceLeft` here would lose a live recording
755 // to a formatting detail. Growing the allocation cannot overflow.
756 const rendered = try std.fmt.allocPrint(allocator, "{d}", .{started});
757 defer allocator.free(rendered);
758 try out.appendSlice(allocator, rendered);
759 } else {
760 try out.appendSlice(allocator, "null");
761 }
762 try out.appendSlice(allocator, "}");
763
764 return out.toOwnedSlice(allocator);
765}
766
767/// Start a fresh track, replacing whatever the last recording left.
768///
769/// `startLocationRecording` writes an empty `Data()` with `.atomic` before it
770/// touches any state (`CraftApp.swift:3213-3220`), so a new recording never
771/// inherits the previous route's samples, and a failure refuses the start
772/// rather than recording into the old file.
773pub fn resetTrack(allocator: std.mem.Allocator) !void {
774 const path = try trackFilePath(allocator);
775 defer allocator.free(path);
776
777 try writeFileAtomic(allocator, path, "");
778 protectFile(path);
779}
780
781/// Append one already-shaped sample object, with the newline that separates it.
782///
783/// `appendRecordedLocation` (`CraftApp.swift:3288-3302`) opens the track, seeks
784/// to the end and writes the JSON plus `0x0A`. `length` + `writePositionalAll`
785/// is that pair, and it is the reason this is not a read-modify-write: a route
786/// of 176,000 fixes rewritten once per second is not a recorder.
787///
788/// The sample arrives as bytes that `didUpdateLocations` already shaped for the
789/// page, so the track holds exactly what the event carried. Nothing is
790/// reparsed, and nothing can be rounded on the way in.
791pub fn appendSample(allocator: std.mem.Allocator, sample_json: []const u8) !void {
792 const path = try trackFilePath(allocator);
793 defer allocator.free(path);
794 return appendSampleTo(allocator, path, sample_json);
795}
796
797/// `appendSample` against a named track.
798fn appendSampleTo(allocator: std.mem.Allocator, path: []const u8, sample_json: []const u8) !void {
799 const io = io_context.get();
800
801 // Swift's `FileHandle(forWritingTo:)` throws when the file is not there,
802 // and its `catch` prints and drops the sample. Opening write-only without
803 // creating keeps that: a track that does not exist means no recording is
804 // running, and creating one here would start a route nobody asked for.
805 const file = std.Io.Dir.cwd().openFile(io, path, .{ .mode = .write_only }) catch |err| {
806 std.log.warn(
807 "location recording: could not open the track at '{s}' to append a sample ({}); the fix is lost",
808 .{ path, err },
809 );
810 return bridge_error.BridgeError.NativeCallFailed;
811 };
812 defer file.close(io);
813
814 const end = file.length(io) catch |err| {
815 std.log.warn("location recording: could not measure the track at '{s}' ({})", .{ path, err });
816 return bridge_error.BridgeError.NativeCallFailed;
817 };
818
819 // One write, not two. A sample and its newline that reach the file
820 // separately can be torn apart by a kill between them, and a half-written
821 // line is the one thing the reader has to throw away.
822 const line = try std.fmt.allocPrint(allocator, "{s}\n", .{sample_json});
823 defer allocator.free(line);
824
825 file.writePositionalAll(io, line, end) catch |err| {
826 std.log.warn("location recording: could not append to the track at '{s}' ({})", .{ path, err });
827 return bridge_error.BridgeError.NativeCallFailed;
828 };
829}
830
831/// Every sample as the bare JSON array `readLocationRecording` resolves.
832///
833/// `stopLocationRecording` puts this same array under a `locations` key
834/// (`CraftApp.swift:3264-3267`), so the two actions cannot disagree about which
835/// lines survive: they call the same reader.
836///
837/// Caller owns the result. A missing track is `[]`.
838pub fn readTrackArray(allocator: std.mem.Allocator) ![]u8 {
839 const path = try trackFilePath(allocator);
840 defer allocator.free(path);
841
842 const text = try readTrackBytes(allocator, path) orelse
843 return allocator.dupe(u8, empty_array);
844 defer allocator.free(text);
845
846 return shapeRecording(allocator, text);
847}
848
849/// How many samples `readLocationRecording` would return.
850///
851/// `getLocationRecordingState` reports `loadRecordedLocations().count`
852/// (`CraftApp.swift:3274`), so this has to agree with the reader line for line
853/// — the same blank-line and non-object drops — or the page is told it has a
854/// number of fixes it cannot then read back.
855pub fn sampleCount(allocator: std.mem.Allocator) !usize {
856 const path = try trackFilePath(allocator);
857 defer allocator.free(path);
858 return countSamplesIn(allocator, path);
859}
860
861/// `sampleCount` against a named track.
862fn countSamplesIn(allocator: std.mem.Allocator, path: []const u8) !usize {
863 const bytes = (try readTrackBytes(allocator, path)) orelse return 0;
864 defer allocator.free(bytes);
865
866 var count: usize = 0;
867 var lines = std.mem.splitScalar(u8, bytes, '\n');
868 while (lines.next()) |line| {
869 if (line.len == 0) continue;
870 if (try lineIsJsonObject(allocator, line)) count += 1;
871 }
872 return count;
873}
874
875/// Read a small file whole, or null when it is not there.
876fn readSmallFile(allocator: std.mem.Allocator, path: []const u8, ceiling: u64) !?[]u8 {
877 const io = io_context.get();
878
879 const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
880 error.FileNotFound => return null,
881 else => {
882 std.log.warn("location recording: could not open '{s}': {}", .{ path, err });
883 return bridge_error.BridgeError.NativeCallFailed;
884 },
885 };
886 defer file.close(io);
887
888 const size = file.length(io) catch |err| {
889 std.log.warn("location recording: could not measure '{s}': {}", .{ path, err });
890 return bridge_error.BridgeError.NativeCallFailed;
891 };
892 if (size > ceiling) {
893 std.log.warn(
894 "location recording: '{s}' is {d} bytes, over the {d}-byte ceiling; refusing to read it",
895 .{ path, size, ceiling },
896 );
897 return bridge_error.BridgeError.NativeCallFailed;
898 }
899
900 const buf = try allocator.alloc(u8, @intCast(size));
901 errdefer allocator.free(buf);
902
903 var read: usize = 0;
904 while (read < buf.len) {
905 const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) {
906 error.EndOfStream => break,
907 else => {
908 std.log.warn("location recording: could not read '{s}': {}", .{ path, err });
909 return bridge_error.BridgeError.NativeCallFailed;
910 },
911 };
912 if (n == 0) break;
913 read += n;
914 }
915
916 return buf[0..read];
917}
918
919/// Write `bytes` to `path` so that a reader sees either the old file or the
920/// whole new one, never a half-written document.
921///
922/// This is what `Data.write(options: .atomic)` does — a sibling temp file and a
923/// rename — and it matters for the same reason it does there: the state file is
924/// read at launch, and a launch that lands mid-write would restore a torn
925/// recording.
926fn writeFileAtomic(allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
927 const io = io_context.get();
928
929 const temp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path});
930 defer allocator.free(temp_path);
931
932 {
933 const file = std.Io.Dir.cwd().createFile(io, temp_path, .{}) catch |err| {
934 std.log.warn("location recording: could not create '{s}': {}", .{ temp_path, err });
935 return bridge_error.BridgeError.NativeCallFailed;
936 };
937 defer file.close(io);
938
939 file.writeStreamingAll(io, bytes) catch |err| {
940 std.log.warn("location recording: could not write '{s}': {}", .{ temp_path, err });
941 return bridge_error.BridgeError.NativeCallFailed;
942 };
943 }
944
945 const dir = std.Io.Dir.cwd();
946 std.Io.Dir.rename(dir, temp_path, dir, path, io) catch |err| {
947 std.log.warn("location recording: could not move '{s}' into place at '{s}': {}", .{ temp_path, path, err });
948 // A temp file left behind is litter; a temp file left behind *and*
949 // reported as success is a recording nobody can find.
950 std.Io.Dir.deleteFile(dir, io, temp_path) catch {};
951 return bridge_error.BridgeError.NativeCallFailed;
952 };
953}
954
955/// Apply `protectLocationFile`'s two attributes: data protection, and exclusion
956/// from backup. `CraftApp.swift:3304-3311`.
957///
958/// Best effort, and deliberately so — Swift's are both `try?`. A route that
959/// records is better than a route refused because one attribute would not
960/// stick, and the two failures differ in kind: without protection the file is
961/// readable before first unlock, without the backup flag it leaves the device
962/// in an iCloud backup. Both are logged by name so neither is silent.
963///
964/// Off Darwin this does nothing and says nothing: there is no data protection
965/// to apply, and the host tests write their fixtures into the working
966/// directory.
967fn protectFile(path: []const u8) void {
968 if (!is_darwin) return;
969
970 const ns_path = makeNSString(path) orelse {
971 std.log.warn("location recording: could not make an NSString for '{s}'; file attributes not applied", .{path});
972 return;
973 };
974
975 setProtection(ns_path, path);
976 excludeFromBackup(ns_path, path);
977}
978
979/// `NSFileProtectionKey` → `NSFileProtectionCompleteUntilFirstUserAuthentication`.
980///
981/// Both names are `extern NSString * const`, so their values are not in any
982/// header and are read out of whatever loaded Foundation rather than spelled
983/// out here — the same `dlsym` route `bestAccuracy` takes in
984/// `bridge_mobile_location.zig`.
985fn setProtection(ns_path: Id, path: []const u8) void {
986 const key = nsStringConstant("NSFileProtectionKey") orelse {
987 std.log.warn("location recording: NSFileProtectionKey is not in this process; '{s}' is unprotected", .{path});
988 return;
989 };
990 const value = nsStringConstant("NSFileProtectionCompleteUntilFirstUserAuthentication") orelse {
991 std.log.warn("location recording: NSFileProtectionCompleteUntilFirstUserAuthentication is not in this process; '{s}' is unprotected", .{path});
992 return;
993 };
994
995 const NSDictionary = objc.objc_getClass("NSDictionary") orelse return;
996 const sel_dict = objc.sel_registerName("dictionaryWithObject:forKey:") orelse return;
997 const attributes = objc.msgSendId2(NSDictionary, sel_dict, value, key) orelse return;
998
999 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return;
1000 const sel_default = objc.sel_registerName("defaultManager") orelse return;
1001 const manager = objc.msgSendId(NSFileManager, sel_default) orelse return;
1002
1003 const sel_set = objc.sel_registerName("setAttributes:ofItemAtPath:error:") orelse return;
1004 const SetFn = *const fn (Id, Id, Id, Id, ?*anyopaque) callconv(.c) bool;
1005 const set: SetFn = @ptrCast(&objc.objc_msgSend);
1006
1007 if (!set(manager, sel_set, attributes, ns_path, null)) {
1008 std.log.warn("location recording: setAttributes: refused for '{s}'; the file is readable before first unlock", .{path});
1009 }
1010}
1011
1012/// `NSURLIsExcludedFromBackupKey` = YES, via `NSURL`.
1013fn excludeFromBackup(ns_path: Id, path: []const u8) void {
1014 const key = nsStringConstant("NSURLIsExcludedFromBackupKey") orelse {
1015 std.log.warn("location recording: NSURLIsExcludedFromBackupKey is not in this process; '{s}' will be backed up", .{path});
1016 return;
1017 };
1018
1019 const NSURL = objc.objc_getClass("NSURL") orelse return;
1020 const sel_file_url = objc.sel_registerName("fileURLWithPath:") orelse return;
1021 const url = objc.msgSendId1(NSURL, sel_file_url, ns_path) orelse return;
1022
1023 const NSNumber = objc.objc_getClass("NSNumber") orelse return;
1024 const sel_with_bool = objc.sel_registerName("numberWithBool:") orelse return;
1025 const WithBoolFn = *const fn (Id, Id, bool) callconv(.c) Id;
1026 const with_bool: WithBoolFn = @ptrCast(&objc.objc_msgSend);
1027 const yes = with_bool(NSNumber, sel_with_bool, true) orelse return;
1028
1029 const sel_set = objc.sel_registerName("setResourceValue:forKey:error:") orelse return;
1030 const SetFn = *const fn (Id, Id, Id, Id, ?*anyopaque) callconv(.c) bool;
1031 const set: SetFn = @ptrCast(&objc.objc_msgSend);
1032
1033 if (!set(url, sel_set, yes, key, null)) {
1034 std.log.warn("location recording: setResourceValue: refused for '{s}'; the recording will be included in backups", .{path});
1035 }
1036}
1037
1038/// Read an `extern NSString * const` out of the loaded frameworks.
1039///
1040/// The symbol is the *cell*, not the string: dereference it to get the object.
1041fn nsStringConstant(name: [*:0]const u8) ?Id {
1042 const symbol = dlsym(RTLD_DEFAULT, name) orelse return null;
1043 const cell: *const Id = @ptrCast(@alignCast(symbol));
1044 return cell.*;
1045}
1046
1047extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
1048
1049/// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin.
1050const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
1051
1052/// An autoreleased `NSString` for a Zig slice, via `stringWithUTF8String:`.
1053fn makeNSString(text: []const u8) ?Id {
1054 var buf: [std.fs.max_path_bytes + 1]u8 = undefined;
1055 if (text.len >= buf.len) return null;
1056 @memcpy(buf[0..text.len], text);
1057 buf[text.len] = 0;
1058
1059 const NSString = objc.objc_getClass("NSString") orelse return null;
1060 const sel = objc.sel_registerName("stringWithUTF8String:") orelse return null;
1061 return objc.msgSendId1(NSString, sel, @as([*:0]const u8, @ptrCast(&buf)));
1062}
1063
5911064// =============================================================================
5921065// Tests.
5931066//
@@ -662,18 +1135,15 @@ test "every route the dispatcher has is a declared action" {
6621135 }
6631136}
6641137
665test "the five stateful recording actions are left to the Swift shim" {
666 // 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.
6671141 //
668 // `ios_dispatch`'s chain treats UnknownAction as "not mine, ask the next"
669 // and any other error as final. So the *only* spelling of "let the shim
670 // keep serving this" is: absent from `A`, absent from `routeFor`, and
671 // UnknownAction out of `handleMessage`. An `.unavailable` declaration would
672 // dispatch and refuse five actions the shim answers correctly today —
673 // including the `enableGeolocation` gate, the authorization mode,
674 // background continuation, atomic persistence, file protection, backup
675 // exclusion and relaunch restore, none of which Zig can reproduce while
676 // `Coordinator.init` still re-adopts the recording on every launch.
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.
6771147 const unserved = [_][]const u8{
6781148 "startLocationRecording",
6791149 "pauseLocationRecording",
@@ -1182,3 +1652,224 @@ test "with no recording on the host, the handler answers rather than failing" {
11821652
11831653 try bridge.handleMessage(A.read_location_recording, "{}");
11841654}
1655
1656// =============================================================================
1657// The on-disk format.
1658//
1659// The guard in `decodeState` is the one that closes the third of the three
1660// blockers #124 lists, so it is pinned from both sides: a stopped recording's
1661// leftovers must read back as nothing, and a live one must read back whole.
1662// Everything here runs against bytes or against a file this test owns; nothing
1663// touches `~/Library/Application Support`.
1664// =============================================================================
1665
1666const test_state_name = "craft-locrecording-test-state.json";
1667
1668fn writeTestFile(name: []const u8, contents: []const u8) !void {
1669 const io = io_context.get();
1670 const file = try std.Io.Dir.cwd().createFile(io, name, .{});
1671 defer file.close(io);
1672 try file.writeStreamingAll(io, contents);
1673}
1674
1675fn removeTestFile(name: []const u8) void {
1676 std.Io.Dir.cwd().deleteFile(io_context.get(), name) catch |err| {
1677 std.log.debug("locrecording test file cleanup failed: {}", .{err});
1678 };
1679}
1680
1681test "a stopped recording's leftovers read back as no recording at all" {
1682 // The exact divergence #124's third blocker names. `stopLocationRecording`
1683 // sets `active:false` but leaves `id` and `startedAt` in the file, and
1684 // Swift's `guard state["active"] as? Bool == true else { return }` means a
1685 // relaunch after a stop restores nothing. A reader that handed back the id
1686 // would answer `{"id":"ABC-123"}` where Swift answers `{"id":null}`.
1687 var state = try decodeState(testing.allocator,
1688 \\{"active":false,"paused":false,"id":"ABC-123","startedAt":1756900000000}
1689 );
1690 defer state.deinit(testing.allocator);
1691
1692 try testing.expect(!state.active);
1693 try testing.expect(!state.paused);
1694 try testing.expect(state.id == null);
1695 try testing.expect(state.started_at == null);
1696}
1697
1698test "an active recording restores all four fields" {
1699 var state = try decodeState(testing.allocator,
1700 \\{"active":true,"paused":true,"id":"ABC-123","startedAt":1756900000000}
1701 );
1702 defer state.deinit(testing.allocator);
1703
1704 try testing.expect(state.active);
1705 try testing.expect(state.paused);
1706 try testing.expectEqualStrings("ABC-123", state.id.?);
1707 try testing.expectEqual(@as(f64, 1756900000000), state.started_at.?);
1708}
1709
1710test "the active guard is a Bool cast, not a truthiness test" {
1711 // `as? Bool` fails for each of these, and every failure lands on the same
1712 // `return`. A JSON reader that coerced would restore a recording Swift
1713 // does not.
1714 const not_bools = [_][]const u8{
1715 \\{"active":"true","id":"ABC","startedAt":1}
1716 ,
1717 \\{"active":1,"id":"ABC","startedAt":1}
1718 ,
1719 \\{"active":null,"id":"ABC","startedAt":1}
1720 ,
1721 \\{"paused":false,"id":"ABC","startedAt":1}
1722 ,
1723 \\["active",true]
1724 ,
1725 \\not json at all
1726 ,
1727 };
1728
1729 for (not_bools) |document| {
1730 var state = try decodeState(testing.allocator, document);
1731 defer state.deinit(testing.allocator);
1732 try testing.expect(!state.active);
1733 try testing.expect(state.id == null);
1734 try testing.expect(state.started_at == null);
1735 }
1736}
1737
1738test "past the guard a wrong type is a default, not a refusal" {
1739 // Swift's remaining reads are `as? Bool ?? false`, `as? String` and
1740 // `as? TimeInterval`, all of which degrade rather than throw.
1741 var state = try decodeState(testing.allocator,
1742 \\{"active":true,"paused":"yes","id":42,"startedAt":"soon"}
1743 );
1744 defer state.deinit(testing.allocator);
1745
1746 try testing.expect(state.active);
1747 try testing.expect(!state.paused);
1748 try testing.expect(state.id == null);
1749 try testing.expect(state.started_at == null);
1750}
1751
1752test "the encoded state carries the four keys Swift's decoder looks for" {
1753 const encoded = try encodeState(testing.allocator, .{
1754 .active = true,
1755 .paused = false,
1756 .id = "ABC-123",
1757 .started_at = 1756900000000,
1758 });
1759 defer testing.allocator.free(encoded);
1760
1761 try testing.expectEqualStrings(
1762 \\{"active":true,"paused":false,"id":"ABC-123","startedAt":1756900000000}
1763 , encoded);
1764}
1765
1766test "a recording that has never started encodes its nulls, not empty strings" {
1767 const encoded = try encodeState(testing.allocator, .{});
1768 defer testing.allocator.free(encoded);
1769
1770 try testing.expectEqualStrings(
1771 \\{"active":false,"paused":false,"id":null,"startedAt":null}
1772 , encoded);
1773}
1774
1775test "state survives a round trip through the encoder and the guard" {
1776 const encoded = try encodeState(testing.allocator, .{
1777 .active = true,
1778 .paused = true,
1779 .id = "route-9",
1780 .started_at = 1756900123456,
1781 });
1782 defer testing.allocator.free(encoded);
1783
1784 var state = try decodeState(testing.allocator, encoded);
1785 defer state.deinit(testing.allocator);
1786
1787 try testing.expect(state.active);
1788 try testing.expect(state.paused);
1789 try testing.expectEqualStrings("route-9", state.id.?);
1790 try testing.expectEqual(@as(f64, 1756900123456), state.started_at.?);
1791}
1792
1793test "an id with quotes in it survives rather than tearing the document" {
1794 const encoded = try encodeState(testing.allocator, .{
1795 .active = true,
1796 .id = "a\"b\\c",
1797 });
1798 defer testing.allocator.free(encoded);
1799
1800 var state = try decodeState(testing.allocator, encoded);
1801 defer state.deinit(testing.allocator);
1802
1803 try testing.expectEqualStrings("a\"b\\c", state.id.?);
1804}
1805
1806test "a missing state file is no recording, not an error" {
1807 removeTestFile(test_state_name);
1808
1809 var state = try readStateFrom(testing.allocator, test_state_name);
1810 defer state.deinit(testing.allocator);
1811
1812 try testing.expect(!state.active);
1813}
1814
1815test "appended samples land one per line, byte for byte" {
1816 removeTestFile(test_track_name);
1817 defer removeTestFile(test_track_name);
1818
1819 // The track has to exist first: `FileHandle(forWritingTo:)` throws when it
1820 // does not, and appending would otherwise start a route nobody asked for.
1821 try writeTestFile(test_track_name, "");
1822
1823 try appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":1.5}");
1824 try appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":2.5}");
1825
1826 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1827 return error.TestExpectedTrack;
1828 defer testing.allocator.free(text);
1829
1830 try testing.expectEqualStrings("{\"latitude\":1.5}\n{\"latitude\":2.5}\n", text);
1831}
1832
1833test "appending to a track that is not there loses the sample rather than creating one" {
1834 removeTestFile(test_track_name);
1835
1836 // No recording is running, so there is no route for this fix to join.
1837 // Creating the file here would invent one.
1838 try testing.expectError(
1839 bridge_error.BridgeError.NativeCallFailed,
1840 appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":1.5}"),
1841 );
1842}
1843
1844test "the sample count agrees with the reader, drop for drop" {
1845 removeTestFile(test_track_name);
1846 defer removeTestFile(test_track_name);
1847
1848 // `getLocationRecordingState` reports `loadRecordedLocations().count`, so a
1849 // blank line and a non-object have to be uncounted here exactly as they are
1850 // unread there — otherwise the page is told it has fixes it cannot read.
1851 try writeTestFile(test_track_name,
1852 \\{"latitude":1}
1853 \\
1854 \\[1,2]
1855 \\{"latitude":2}
1856 \\torn-half-
1857 );
1858
1859 try testing.expectEqual(@as(usize, 2), try countSamplesIn(testing.allocator, test_track_name));
1860
1861 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1862 return error.TestExpectedTrack;
1863 defer testing.allocator.free(text);
1864
1865 const shaped = try shapeRecording(testing.allocator, text);
1866 defer testing.allocator.free(shaped);
1867
1868 // Two objects in, two objects out: the count is the reader's own answer.
1869 try testing.expectEqualStrings("[{\"latitude\":1},{\"latitude\":2}]", shaped);
1870}
1871
1872test "an absent track counts zero rather than refusing" {
1873 removeTestFile(test_track_name);
1874 try testing.expectEqual(@as(usize, 0), try countSamplesIn(testing.allocator, test_track_name));
1875}
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