⋯ 284 lines Show the 284 lines above this hunk @@ -285,6 +285,8 @@ const bridge_error = ; 285 285 const objc_runtime = ; 286 286 const ios_async = ; 287 287 const ios_events = ; 288 + const locrec = ; 289 + const compat = ; 288 290 const compat_mutex = ; 289 291 290 292 const objc = objc_runtime.objc; ⋯ 19 lines Show the 19 lines above this hunk @@ -310,6 +312,11 @@ pub const A = struct { 310 312 pub const get_current_position = "getCurrentPosition"; 311 313 pub const watch_position = "watchPosition"; 312 314 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"; 313 320 }; 314 321 315 322 /// `.result`: the Swift path terminates in exactly one `resolveCallback` (from ⋯ 16 lines Show the 16 lines above this hunk @@ -332,6 +339,21 @@ pub const capability_actions = [_]capabilities.ActionDecl{ 332 339 .{ .name = A.get_current_position, .reply = .result }, 333 340 .{ .name = A.watch_position, .reply = .result }, 334 341 .{ .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 }, 335 357 }; 336 358 337 359 /// The reply `watchPosition` and `clearWatch` send. ⋯ 7 lines Show the 7 lines above this hunk @@ -345,12 +367,26 @@ const true_fragment = "true"; 345 367 /// Which handler an action selects, split out from `handleMessage` so the 346 368 /// table-versus-dispatch agreement is assertable on a host without touching 347 369 /// CoreLocation. 348 - const Route = enum { current_position, watch_position, clear_watch }; 370 + const 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 + }; 349 380 350 381 fn routeFor(action: []const u8) ?Route { 351 382 if (std.mem.eql(u8, action, A.get_current_position)) return .current_position; 352 383 if (std.mem.eql(u8, action, A.watch_position)) return .watch_position; 353 384 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; 354 390 return null; 355 391 } 356 392 ⋯ 15 lines Show the 15 lines above this hunk @@ -372,6 +408,11 @@ pub const LocationBridge = struct { 372 408 .current_position => self.getCurrentPosition(data), 373 409 .watch_position => self.watchPosition(data), 374 410 .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), 375 416 }; 376 417 } 377 418 ⋯ 79 lines Show the 79 lines above this hunk @@ -457,42 +498,16 @@ pub const LocationBridge = struct { 457 498 458 499 const authorization = try resolveAuthorization(); 459 500 const sels = try Sels.resolve(); 501 + const updates = try UpdateSelectors.resolve(authorization); 460 502 const background = try backgroundLocationDeclared(); 461 503 const mgr = try ensureManager(); 462 504 463 - const auth_selector_name: [*:0]const u8 = switch (authorization) { 464 - .always => "requestAlwaysAuthorization", 465 - .when_in_use => "requestWhenInUseAuthorization", 466 - }; 467 - const sel_authorize = try selector(auth_selector_name); 468 - const sel_start = try selector("startUpdatingLocation"); 469 - const sel_responds = try selector("respondsToSelector:"); 470 - const sel_allows_background = try selector("setAllowsBackgroundLocationUpdates:"); 471 - const sel_shows_indicator = try selector("setShowsBackgroundLocationIndicator:"); 472 - 473 505 publishWatch(sels); 474 506 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); 496 511 497 512 bridge_error.sendResultToJS(self.allocator, A.watch_position, true_fragment); 498 513 } ⋯ 59 lines Show the 59 lines above this hunk @@ -558,6 +573,20 @@ pub const LocationBridge = struct { 558 573 return bridge_error.BridgeError.NativeCallFailed; 559 574 }; 560 575 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 + 561 590 objc.msgSend(mgr, sel_stop); 562 591 // Mirrors `stopWatchingPosition()`'s `allowsBackgroundLocationUpdates = 563 592 // false`. Setting it to NO is safe in any process — only YES can raise. ⋯ 1 line Show the 1 line above this hunk @@ -565,8 +594,498 @@ pub const LocationBridge = struct { 565 594 566 595 bridge_error.sendResultToJS(self.allocator, A.clear_watch, true_fragment); 567 596 } 597 + 598 + // ========================================================================= 599 + // The recorder. 600 + // 601 + // Five actions over one flag and two files. They share the manager with the 602 + // watch, which is the whole reason they belong in this file rather than 603 + // beside the track reader: `CraftWebView.Coordinator` runs one 604 + // `CLLocationManager` for all of `getCurrentPosition`, `watchPosition` and 605 + // the recorder, and two managers feeding one event name is the duplication 606 + // this migration removes. 607 + // 608 + // Swift keeps no guard against starting a recording while one is running — 609 + // `startLocationRecording` overwrites the track and the id unconditionally 610 + // — and neither does this. It is a restart, and the page asked for one. 611 + // ========================================================================= 612 + 613 + /// Begin a route, replacing whatever the last one left behind. 614 + /// 615 + /// The order differs from Swift's in one place, deliberately. Swift sets its 616 + /// four variables, then calls `persistLocationRecordingState()`, which 617 + /// catches its own write failure and prints. Memory and disk can therefore 618 + /// disagree from the first sample onward, with the page told the recording 619 + /// started. Here the state reaches disk *first*, and a failure refuses the 620 + /// start: a recording the page is told is running is one that will still be 621 + /// there after a relaunch. 622 + fn startRecording(self: *Self, data: []const u8) !void { 623 + _ = data; 624 + if (!is_darwin) return error.UnsupportedPlatform; 625 + 626 + // Everything that can fail, resolved before the track is cleared. A 627 + // refusal after `resetTrack` would have destroyed the previous route to 628 + // serve a call that then answers an error. 629 + const authorization = try resolveAuthorization(); 630 + const sels = try Sels.resolve(); 631 + const updates = try UpdateSelectors.resolve(authorization); 632 + const background = try backgroundLocationDeclared(); 633 + const mgr = try ensureManager(); 634 + 635 + // `commitRecording` copies it, so this frame owns it either way. 636 + const id = try newRecordingId(recording_allocator); 637 + defer recording_allocator.free(id); 638 + 639 + const started_at = nowMillis(); 640 + const next: locrec.State = .{ 641 + .active = true, 642 + .paused = false, 643 + .id = id, 644 + .started_at = started_at, 645 + }; 646 + 647 + // `try Data().write(to:…, options:.atomic)`: a fresh, empty track. A 648 + // storage failure refuses the start rather than appending this route on 649 + // to the last one — `RECORDING_STORAGE_ERROR` in Swift, and the nearest 650 + // `BridgeError` here, with the cause logged by the writer. 651 + try locrec.resetTrack(self.allocator); 652 + try locrec.writeState(self.allocator, next); 653 + 654 + try commitRecording(next, sels); 655 + applyUpdates(mgr, updates, background, .request); 656 + 657 + try self.replySummary(A.start_location_recording, .none); 658 + } 659 + 660 + /// Stop collecting without ending the route. 661 + /// 662 + /// `guard isRecordingLocation else { reject("No active location recording") }`. 663 + /// Paused is a state of a live recording, so a paused one still answers 664 + /// `pause` — Swift's guard is on `isRecordingLocation` alone, not on 665 + /// `!isLocationRecordingPaused`, and pausing twice is not an error. 666 + fn pauseRecording(self: *Self, data: []const u8) !void { 667 + _ = data; 668 + if (!is_darwin) return error.UnsupportedPlatform; 669 + 670 + var snapshot = try snapshotRecording(self.allocator); 671 + defer snapshot.deinit(self.allocator); 672 + 673 + if (!snapshot.active) return noActiveRecording(); 674 + 675 + const next: locrec.State = .{ 676 + .active = true, 677 + .paused = true, 678 + .id = snapshot.id, 679 + .started_at = snapshot.started_at, 680 + }; 681 + try locrec.writeState(self.allocator, next); 682 + try commitRecording(next, null); 683 + 684 + // `if !isWatchingLocation { locationManager?.stopUpdatingLocation() }`. 685 + // The watch keeps the manager alive when it is the other user of it. 686 + stopUpdatesUnlessWatched("pauseLocationRecording", .keep_background); 687 + 688 + try self.replySummary(A.pause_location_recording, .none); 689 + } 690 + 691 + /// Collect again on a route that was paused. 692 + fn resumeRecording(self: *Self, data: []const u8) !void { 693 + _ = data; 694 + if (!is_darwin) return error.UnsupportedPlatform; 695 + 696 + const authorization = try resolveAuthorization(); 697 + const sels = try Sels.resolve(); 698 + const updates = try UpdateSelectors.resolve(authorization); 699 + const background = try backgroundLocationDeclared(); 700 + const mgr = try ensureManager(); 701 + 702 + var snapshot = try snapshotRecording(self.allocator); 703 + defer snapshot.deinit(self.allocator); 704 + 705 + if (!snapshot.active) return noActiveRecording(); 706 + 707 + const next: locrec.State = .{ 708 + .active = true, 709 + .paused = false, 710 + .id = snapshot.id, 711 + .started_at = snapshot.started_at, 712 + }; 713 + try locrec.writeState(self.allocator, next); 714 + try commitRecording(next, sels); 715 + 716 + applyUpdates(mgr, updates, background, .request); 717 + 718 + try self.replySummary(A.resume_location_recording, .none); 719 + } 720 + 721 + /// End the route and hand back everything it collected. 722 + /// 723 + /// No guard: `stopLocationRecording` has none in Swift either, so stopping 724 + /// a recording that is not running answers the summary of a stopped 725 + /// recording rather than an error. "There is no recording" and "the 726 + /// recording is now stopped" are the same state to a caller. 727 + /// 728 + /// `id` and `startedAt` are deliberately **not** cleared, matching Swift — 729 + /// which is why a stop-then-read in one launch still reports them, and a 730 + /// relaunch does not. 731 + fn stopRecording(self: *Self, data: []const u8) !void { 732 + _ = data; 733 + if (!is_darwin) return error.UnsupportedPlatform; 734 + 735 + var snapshot = try snapshotRecording(self.allocator); 736 + defer snapshot.deinit(self.allocator); 737 + 738 + const next: locrec.State = .{ 739 + .active = false, 740 + .paused = false, 741 + .id = snapshot.id, 742 + .started_at = snapshot.started_at, 743 + }; 744 + try locrec.writeState(self.allocator, next); 745 + try commitRecording(next, null); 746 + 747 + stopUpdatesUnlessWatched("stopLocationRecording", .drop_background); 748 + 749 + try self.replySummary(A.stop_location_recording, .with_locations); 750 + } 751 + 752 + /// The summary, plus how many samples are on disk. 753 + fn recordingState(self: *Self, data: []const u8) !void { 754 + _ = data; 755 + try self.replySummary(A.get_location_recording_state, .with_sample_count); 756 + } 757 + 758 + /// What the four summary actions reply, in Swift's key order. 759 + /// 760 + /// `locationRecordingSummary()` is `id`, `active`, `paused`, `startedAt`; 761 + /// `stopLocationRecording` adds `locations` and `getLocationRecordingState` 762 + /// adds `sampleCount`, and no other action carries either. 763 + fn replySummary(self: *Self, comptime action: []const u8, extra: SummaryExtra) !void { 764 + var snapshot = try snapshotRecording(self.allocator); 765 + defer snapshot.deinit(self.allocator); 766 + 767 + var out: std.ArrayListUnmanaged(u8) = .empty; 768 + defer out.deinit(self.allocator); 769 + 770 + try out.appendSlice(self.allocator, "{\"id\":"); 771 + if (snapshot.id) |id| { 772 + try out.append(self.allocator, '"'); 773 + try bridge_error.appendJsonEscaped(self.allocator, &out, id); 774 + try out.append(self.allocator, '"'); 775 + } else { 776 + try out.appendSlice(self.allocator, "null"); 777 + } 778 + try out.appendSlice(self.allocator, ",\"active\":"); 779 + try out.appendSlice(self.allocator, if (snapshot.active) "true" else "false"); 780 + try out.appendSlice(self.allocator, ",\"paused\":"); 781 + try out.appendSlice(self.allocator, if (snapshot.paused) "true" else "false"); 782 + try out.appendSlice(self.allocator, ",\"startedAt\":"); 783 + if (snapshot.started_at) |started| { 784 + const rendered = try std.fmt.allocPrint(self.allocator, "{d}", .{started}); 785 + defer self.allocator.free(rendered); 786 + try out.appendSlice(self.allocator, rendered); 787 + } else { 788 + try out.appendSlice(self.allocator, "null"); 789 + } 790 + 791 + switch (extra) { 792 + .none => {}, 793 + .with_locations => { 794 + const locations = try locrec.readTrackArray(self.allocator); 795 + defer self.allocator.free(locations); 796 + try out.appendSlice(self.allocator, ",\"locations\":"); 797 + try out.appendSlice(self.allocator, locations); 798 + }, 799 + .with_sample_count => { 800 + const count = try locrec.sampleCount(self.allocator); 801 + var buf: [32]u8 = undefined; 802 + const rendered = try std.fmt.bufPrint(&buf, "{d}", .{count}); 803 + try out.appendSlice(self.allocator, ",\"sampleCount\":"); 804 + try out.appendSlice(self.allocator, rendered); 805 + }, 806 + } 807 + 808 + try out.append(self.allocator, '}'); 809 + 810 + bridge_error.sendResultToJS(self.allocator, action, out.items); 811 + } 812 + }; 813 + 814 + 815 + // ============================================================================= 816 + // The recorder's shared machinery: the manager hand-off, the state commit, and 817 + // the two values Swift takes from Foundation. 818 + // ============================================================================= 819 + 820 + /// Re-adopt a recording that outlived the last launch. 821 + /// 822 + /// `restoreLocationRecordingState()`, moved to the runtime that owns the 823 + /// recorder. Swift calls this instead of its own restore, and the reason it has 824 + /// to is an ordering it cannot change: SwiftUI runs `makeCoordinator()` before 825 + /// `makeUIView`, so `Coordinator.init` — where the restore lives — happens 826 + /// before `CraftZigRuntime.attach` has told Zig anything at all. Whoever 827 + /// restores first owns the `CLLocationManager` for the rest of the launch, and 828 + /// a Zig-owned `stop` cannot stop a recording Swift restored into its own 829 + /// state. That ordering was the deferral's whole reason (#124); this is the 830 + /// hook that settles it. 831 + /// 832 + /// Returns **true when Zig has taken responsibility for the recorder**, which 833 + /// is whenever this function ran at all — the caller's `dlsym` finding the 834 + /// symbol is itself the proof. It is deliberately not "a recording was found": 835 + /// a launch with no recording still leaves Zig owning the next `start`, and a 836 + /// Swift restore under it would be a second owner of the same file. 837 + /// 838 + /// Not gated here. The call site is inside Swift's `if config.enableGeolocation`, 839 + /// exactly where `restoreLocationRecordingState()` was, so the flag is honoured 840 + /// by placement rather than by a second, drifting copy of the check. 841 + pub fn adoptRecording() bool { 842 + if (!is_darwin) return false; 843 + 844 + const allocator = recording_allocator; 845 + 846 + var state = locrec.readState(allocator) catch |err| { 847 + // The state file is the only record of a recording in progress, and 848 + // Swift would be reading the same bytes. Reporting ownership anyway is 849 + // the honest answer: falling back would not recover the route, it would 850 + // just add a second reader of a file neither can parse. 851 + std.log.warn( 852 + "location recording: could not read the persisted state at launch ({}); no recording is adopted", 853 + .{err}, 854 + ); 855 + return true; 856 + }; 857 + defer state.deinit(allocator); 858 + 859 + // `guard … state["active"] as? Bool == true else { return }` already 860 + // applied by `readState`, so this is Swift's early return. 861 + if (!state.active) return true; 862 + 863 + const sels = Sels.resolve() catch |err| { 864 + std.log.err("location recording: cannot adopt the recording at launch ({}); its samples will not resume", .{err}); 865 + return true; 866 + }; 867 + 868 + commitRecording(state, sels) catch |err| { 869 + std.log.err("location recording: cannot take the recording into memory at launch ({})", .{err}); 870 + return true; 871 + }; 872 + 873 + // `if !isLocationRecordingPaused { … startUpdatingLocation() }`. A paused 874 + // recording is restored into memory and left alone, so `resume` finds it. 875 + if (state.paused) return true; 876 + 877 + const authorization = resolveAuthorization() catch |err| { 878 + std.log.err("location recording: adopted the recording but cannot resolve authorization ({}); it will not collect until resumed", .{err}); 879 + return true; 880 + }; 881 + const updates = UpdateSelectors.resolve(authorization) catch |err| { 882 + std.log.err("location recording: adopted the recording but cannot resolve its selectors ({})", .{err}); 883 + return true; 884 + }; 885 + const background = backgroundLocationDeclared() catch false; 886 + const mgr = ensureManager() catch |err| { 887 + std.log.err("location recording: adopted the recording but cannot build a CLLocationManager ({})", .{err}); 888 + return true; 889 + }; 890 + 891 + // `.skip`: Swift's restore does not re-request authorization, and a 892 + // permission prompt at launch is not something the page asked for. 893 + applyUpdates(mgr, updates, background, .skip); 894 + return true; 895 + } 896 + 897 + /// What a summary reply carries beyond the four common keys. 898 + const SummaryExtra = enum { none, with_locations, with_sample_count }; 899 + 900 + /// The selectors `startUpdatingLocation` needs, plus the authorization request 901 + /// that precedes it. 902 + /// 903 + /// Resolved as a set before any state changes, so the sequence that actually 904 + /// turns GPS on cannot fail halfway. `watchPosition` and the two recorder 905 + /// actions that start collecting all run the same four lines; keeping one copy 906 + /// is how they stay the same four lines. 907 + const UpdateSelectors = struct { 908 + authorize: Id, 909 + start: Id, 910 + responds: Id, 911 + allows_background: Id, 912 + shows_indicator: Id, 913 + 914 + fn resolve(authorization: Authorization) !UpdateSelectors { 915 + const auth_selector_name: [*:0]const u8 = switch (authorization) { 916 + .always => "requestAlwaysAuthorization", 917 + .when_in_use => "requestWhenInUseAuthorization", 918 + }; 919 + return .{ 920 + .authorize = try selector(auth_selector_name), 921 + .start = try selector("startUpdatingLocation"), 922 + .responds = try selector("respondsToSelector:"), 923 + .allows_background = try selector("setAllowsBackgroundLocationUpdates:"), 924 + .shows_indicator = try selector("setShowsBackgroundLocationIndicator:"), 925 + }; 926 + } 568 927 }; 569 928 929 + /// Request authorization, configure background updates, and start the stream. 930 + /// 931 + /// Infallible by construction: every selector was resolved by 932 + /// `UpdateSelectors.resolve` while an error was still deliverable. 933 + /// Whether the authorization prompt is part of the sequence. 934 + /// 935 + /// Every page-initiated start requests it; `restoreLocationRecordingState` does 936 + /// not, because a recording being re-adopted was authorized when it started and 937 + /// a prompt at launch would be one the page never asked for. 938 + const Authorize = enum { request, skip }; 939 + 940 + fn applyUpdates(mgr: *anyopaque, sels: UpdateSelectors, background: bool, authorize: Authorize) void { 941 + if (authorize == .request) objc.msgSend(mgr, sels.authorize); 942 + 943 + if (background) { 944 + // `configureBackgroundLocationIfNeeded()`, under the guard that makes 945 + // it safe. Both properties take the same value in Swift, and both are 946 + // set to it here. 947 + if (!setBoolIfSupported(mgr, sels.responds, sels.allows_background, true)) { 948 + std.log.warn( 949 + "location: this CLLocationManager has no allowsBackgroundLocationUpdates; " ++ 950 + "updates will stop when the app is backgrounded even though UIBackgroundModes declares location", 951 + .{}, 952 + ); 953 + } 954 + // Purely the status-bar indicator. A manager without it is an OS too 955 + // old for the property, not a broken subscription, so it is not worth a 956 + // warning of its own. 957 + _ = setBoolIfSupported(mgr, sels.responds, sels.shows_indicator, true); 958 + } 959 + 960 + objc.msgSend(mgr, sels.start); 961 + } 962 + 963 + /// Whether `stopUpdatingLocation` also clears `allowsBackgroundLocationUpdates`. 964 + /// 965 + /// `pauseLocationRecording` stops the stream and leaves the property alone; 966 + /// `stopLocationRecording` and `stopWatchingPosition` clear it. Swift makes the 967 + /// same distinction, and it matters: a pause that dropped the property would 968 + /// need the app to be foregrounded to resume. 969 + const BackgroundOnStop = enum { keep_background, drop_background }; 970 + 971 + /// Stop the shared manager, unless the watch is still using it. 972 + /// 973 + /// The recorder's half of the arbitration `clearWatch` does in the other 974 + /// direction. Swift writes it as `if !isWatchingLocation { … }` in both 975 + /// `pauseLocationRecording` and `stopLocationRecording`. 976 + fn stopUpdatesUnlessWatched(comptime who: []const u8, mode: BackgroundOnStop) void { 977 + if (!is_darwin) return; 978 + 979 + if (watchHoldsTheManager()) { 980 + std.log.info( 981 + who ++ ": a watch is still using this manager; leaving it updating", 982 + .{}, 983 + ); 984 + return; 985 + } 986 + 987 + const mgr = manager orelse return; 988 + 989 + const sel_stop = selector("stopUpdatingLocation") catch return; 990 + objc.msgSend(mgr, sel_stop); 991 + 992 + if (mode == .drop_background) { 993 + const sel_responds = selector("respondsToSelector:") catch return; 994 + const sel_allows_background = selector("setAllowsBackgroundLocationUpdates:") catch return; 995 + // Setting it to NO is safe in any process — only YES can raise. 996 + _ = setBoolIfSupported(mgr, sel_responds, sel_allows_background, false); 997 + } 998 + } 999 + 1000 + /// Replace the recorder's state and its subscription in one lock. 1001 + /// 1002 + /// `next.id` is **copied**, not adopted: callers hold their own snapshot for 1003 + /// the reply, and an ownership hand-off across a mutex is the kind of detail 1004 + /// that reads fine and frees twice. 1005 + /// 1006 + /// The subscription follows the state rather than being set separately — 1007 + /// collecting is exactly `active and !paused`, which is 1008 + /// `appendRecordedLocation`'s guard — so there is no arrangement of these two 1009 + /// variables in which the track and the summary disagree. 1010 + fn commitRecording(next: locrec.State, sels: ?Sels) !void { 1011 + const collecting = next.active and !next.paused; 1012 + std.debug.assert(!collecting or sels != null); 1013 + 1014 + const copy: ?[]const u8 = if (next.id) |id| try recording_allocator.dupe(u8, id) else null; 1015 + errdefer if (copy) |c| recording_allocator.free(c); 1016 + 1017 + state_mutex.lock(); 1018 + defer state_mutex.unlock(); 1019 + 1020 + if (recording.id) |old| recording_allocator.free(old); 1021 + recording = .{ 1022 + .active = next.active, 1023 + .paused = next.paused, 1024 + .id = copy, 1025 + .started_at = next.started_at, 1026 + }; 1027 + recording_sels = if (collecting) sels else null; 1028 + } 1029 + 1030 + /// A copy of the recorder's state that outlives the lock. 1031 + fn snapshotRecording(allocator: std.mem.Allocator) !locrec.State { 1032 + state_mutex.lock(); 1033 + defer state_mutex.unlock(); 1034 + 1035 + return .{ 1036 + .active = recording.active, 1037 + .paused = recording.paused, 1038 + .id = if (recording.id) |id| try allocator.dupe(u8, id) else null, 1039 + .started_at = recording.started_at, 1040 + }; 1041 + } 1042 + 1043 + /// `rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")`. 1044 + /// 1045 + /// The text and the code are both lost on the way through `BridgeError`, which 1046 + /// carries an enum rather than a free-text channel — the same trade 1047 + /// `bridge_mobile_misc.zig` and `bridge_mobile_watch.zig` document. 1048 + /// `InvalidParameter` is the migration notes' designated stand-in, so the page 1049 + /// gets `INVALID_PARAMETER` where Swift sent `NO_ACTIVE_RECORDING`. The 1050 + /// rejection itself, which is the part a promise needs, is not lost. 1051 + fn noActiveRecording() bridge_error.BridgeError { 1052 + std.log.warn( 1053 + "location recording: no active recording to pause or resume; rejecting, as Swift's NO_ACTIVE_RECORDING does", 1054 + .{}, 1055 + ); 1056 + return bridge_error.BridgeError.InvalidParameter; 1057 + } 1058 + 1059 + /// `UUID().uuidString`, through `NSUUID` rather than a hand-rolled generator. 1060 + /// 1061 + /// The id is opaque to the page — it is compared, not parsed — so what matters 1062 + /// is that it is unique and that it comes from the same source Swift's does. 1063 + /// Caller owns the result. 1064 + fn newRecordingId(allocator: std.mem.Allocator) ![]u8 { 1065 + if (!is_darwin) return error.UnsupportedPlatform; 1066 + 1067 + const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound; 1068 + const sel_uuid = try selector("UUID"); 1069 + const uuid = objc.msgSendId(NSUUID, sel_uuid) orelse return error.NativeCallFailed; 1070 + 1071 + const text = readNSString(uuid, "UUIDString") orelse return error.NativeCallFailed; 1072 + return allocator.dupe(u8, text); 1073 + } 1074 + 1075 + /// `Date().timeIntervalSince1970 * 1000`. 1076 + /// 1077 + /// Milliseconds since the epoch as an `f64`, which is what the page compares 1078 + /// against its own `Date.now()`. The wall clock, not the monotonic one, for the 1079 + /// same reason Swift's is: it is a timestamp, not a duration. 1080 + /// 1081 + /// `compat.milliTimestamp` rather than `std.time.milliTimestamp`, which 0.17 1082 + /// removed — `bridge_mobile_biometric.zig` takes the same route. Swift's value 1083 + /// carries sub-millisecond precision that this drops; the page compares it 1084 + /// against `Date.now()`, whose resolution is coarser still. 1085 + fn nowMillis() f64 { 1086 + return @floatFromInt(compat.milliTimestamp()); 1087 + } 1088 + 570 1089 /// The answer for a full block pool, copied from `bridge_mobile_permissions`: 571 1090 /// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes' 572 1091 /// designated stand-in, and the point is that the seventeenth concurrent call ⋯ 543 lines Show the 543 lines above this hunk @@ -1116,6 +1635,32 @@ var watch_sels: ?Sels = null; 1116 1635 /// not a guard. 1117 1636 var state_mutex: compat_mutex.Mutex = .{}; 1118 1637 1638 + /// The recorder's four fields, kept where Swift keeps them. 1639 + /// 1640 + /// `CraftWebView.Coordinator` holds `isRecordingLocation`, 1641 + /// `isLocationRecordingPaused`, `locationRecordingId` and 1642 + /// `locationRecordingStartedAt` in memory and mirrors them to disk on every 1643 + /// transition; the file is read back only at launch. Answering 1644 + /// `getLocationRecordingState` from memory rather than from the file is what 1645 + /// makes a stop-then-read in one launch report the stopped recording's id, 1646 + /// where a relaunch reports null — the divergence #124 records, reproduced here 1647 + /// by keeping Swift's arrangement instead of improving on it. 1648 + /// 1649 + /// `id` is owned, allocated from `std.heap.c_allocator`, and freed whenever it 1650 + /// is replaced. 1651 + var recording: locrec.State = .{}; 1652 + 1653 + /// Non-null exactly while fixes belong in the track: active, and not paused. 1654 + /// 1655 + /// The same shape `watch_sels` uses — the presence of the selectors *is* the 1656 + /// subscription — so `didUpdateLocations` asks one question to learn both 1657 + /// whether to append and which names to shape with. 1658 + var recording_sels: ?Sels = null; 1659 + 1660 + /// The allocator behind `recording.id`. Module-level state outlives every 1661 + /// dispatch frame, so it cannot borrow a bridge's allocator. 1662 + const recording_allocator = std.heap.c_allocator; 1663 + 1119 1664 /// Record the call the delegate will answer, returning whatever it displaced so 1120 1665 /// the caller can reject that one rather than drop it. 1121 1666 fn publishPendingFix(ticket: ios_async.Ticket, sels: Sels) ?PendingFix { ⋯ 30 lines Show the 30 lines above this hunk @@ -1152,6 +1697,11 @@ const DelegateWork = struct { 1152 1697 /// until `clearWatch` stops it. Clearing it here would deliver one event 1153 1698 /// and then go silent with the page's callback still registered. 1154 1699 watch: ?Sels, 1700 + /// Read and not cleared, for the same reason: a recording takes every fix 1701 + /// until it is paused or stopped. Null while paused, which is how 1702 + /// `appendRecordedLocation`'s `guard isRecordingLocation && !isLocationRecordingPaused` 1703 + /// is spelled here. 1704 + recording: ?Sels, 1155 1705 }; 1156 1706 1157 1707 fn takeDelegateWork() DelegateWork { ⋯ 1 line Show the 1 line above this hunk @@ -1159,7 +1709,26 @@ fn takeDelegateWork() DelegateWork { 1159 1709 defer state_mutex.unlock(); 1160 1710 const call = pending_fix; 1161 1711 pending_fix = null; 1162 - return .{ .pending = call, .watch = watch_sels }; 1712 + return .{ .pending = call, .watch = watch_sels , .recording = recording_sels }; 1713 + } 1714 + 1715 + /// Whether a recording is collecting right now. 1716 + /// 1717 + /// `stopWatchingPosition`'s `if !isRecordingLocation` guard, which is the reason 1718 + /// `clearWatch` cannot simply stop the manager any more: the recorder and the 1719 + /// watch share one `CLLocationManager`, so whichever stops second is the one 1720 + /// that stops it. 1721 + fn recordingHoldsTheManager() bool { 1722 + state_mutex.lock(); 1723 + defer state_mutex.unlock(); 1724 + return recording_sels != null; 1725 + } 1726 + 1727 + /// Whether a watch is running, for the recorder's half of the same guard. 1728 + fn watchHoldsTheManager() bool { 1729 + state_mutex.lock(); 1730 + defer state_mutex.unlock(); 1731 + return watch_sels != null; 1163 1732 } 1164 1733 1165 1734 /// What `clearWatch` is stopping. ⋯ 53 lines Show the 53 lines above this hunk @@ -1219,9 +1788,11 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void { 1219 1788 const sels = blk: { 1220 1789 if (work.pending) |call| break :blk call.sels; 1221 1790 if (work.watch) |watching| break :blk watching; 1791 + if (work.recording) |collecting| break :blk collecting; 1222 1792 // A fix for a call already answered, or one that arrived after the 1223 - // watch stopped. Nothing to reply to and nobody subscribed . 1224 - std.log.info("location: a fix arrived with no call waiting and no watch running; ignored", .{}); 1793 + // watch stopped. Nothing to reply to , nobody subscribed , and no 1794 + // recording to write it to. 1795 + std.log.info("location: a fix arrived with no call waiting, no watch running and no recording; ignored", .{}); 1225 1796 return; 1226 1797 }; 1227 1798 ⋯ 18 lines Show the 18 lines above this hunk @@ -1246,7 +1817,25 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void { 1246 1817 // to both. A page reading `e.detail.accuracy` and a page reading 1247 1818 // `position.accuracy` are reading the same eight keys. 1248 1819 if (work.pending) |call| ios_async.deliverJson(call.ticket, json); 1249 - if (work.watch != null) ios_events.emit(.location_update, json); 1820 + 1821 + // Swift's order: `resolveCallback`, then `appendRecordedLocation(data)`, 1822 + // then the event. The track gets the same bytes the event carries, because 1823 + // Swift hands one dictionary to both — so a sample read back later is 1824 + // exactly the fix the page saw live. 1825 + // 1826 + // A failed append is logged inside and dropped here, which is 1827 + // `appendRecordedLocation`'s own `catch { print(…) }`. One lost sample must 1828 + // not cost the watch its event or the caller its reply. 1829 + if (work.recording != null) { 1830 + locrec.appendSample(allocator, json) catch {}; 1831 + } 1832 + 1833 + // `if isWatchingLocation || isRecordingLocation`, exactly. This is the 1834 + // guard that made a recording emit `craftLocationUpdate` even with no watch 1835 + // running — and, while the recorder lived in Swift and the watch lived 1836 + // here, the guard that fired *twice* for a page doing both. One manager 1837 + // again, one event per fix. 1838 + if (work.watch != null or work.recording != null) ios_events.emit(.location_update, json); 1250 1839 } 1251 1840 1252 1841 fn didFailWithError(_: Id, _: Id, _: Id, err_object: Id) callconv(.c) void { ⋯ 174 lines Show the 174 lines above this hunk @@ -1427,10 +2016,15 @@ fn readNSInteger(object: Id, comptime name: [*:0]const u8) c_long { 1427 2016 const testing = std.testing; 1428 2017 1429 2018 test "the declared actions are the ones the handler serves" { 1430 - try testing.expectEqual(@as(usize, 3 ), capability_actions.len); 2019 + try testing.expectEqual(@as(usize, 8 ), capability_actions.len); 1431 2020 try testing.expectEqualStrings(A.get_current_position, capability_actions[0].name); 1432 2021 try testing.expectEqualStrings(A.watch_position, capability_actions[1].name); 1433 2022 try testing.expectEqualStrings(A.clear_watch, capability_actions[2].name); 2023 + try testing.expectEqualStrings(A.start_location_recording, capability_actions[3].name); 2024 + try testing.expectEqualStrings(A.pause_location_recording, capability_actions[4].name); 2025 + try testing.expectEqualStrings(A.resume_location_recording, capability_actions[5].name); 2026 + try testing.expectEqualStrings(A.stop_location_recording, capability_actions[6].name); 2027 + try testing.expectEqualStrings(A.get_location_recording_state, capability_actions[7].name); 1434 2028 1435 2029 for (capability_actions) |decl| { 1436 2030 // A `.result` whose handler never replies parks the caller on an untimed ⋯ 1 line Show the 1 line above this hunk @@ -1438,7 +2032,8 @@ test "the declared actions are the ones the handler serves" { 1438 2032 // nothing. Both failure modes are invisible from the page. Swift 1439 2033 // resolves all three of these, so all three are `.result` — including 1440 2034 // `watchPosition`, whose `true` is about the subscribe having started, 1441 - // not about a fix having arrived . 2035 + // not about a fix having arrived , and the five recorder actions, each 2036 + // of which ends in exactly one `resolveCallback` or `rejectCallback`. 1442 2037 try testing.expectEqual(capabilities.Reply.result, decl.reply); 1443 2038 try testing.expectEqual(capabilities.ActionStatus.live, decl.status); 1444 2039 // `.live` with a reason would be a contradiction the manifest shows to apps. ⋯ 116 lines Show the 116 lines above this hunk @@ -1561,15 +2156,12 @@ test "an action the namespace does not serve is reported, not ignored" { 1561 2156 bridge_error.BridgeError.UnknownAction, 1562 2157 bridge.handleMessage("getcurrentposition", "{}"), 1563 2158 ); 1564 - // The neighbouring recording actions belong to another module; two modules 1565 - // answering one action would make `ios_dispatch`'s first-match routing 1566 - // order-dependent. 2159 + // `readLocationRecording` is the one recording action that stays with 2160 + // `bridge_mobile_locrecording.zig`: it reads the track and touches neither 2161 + // the manager nor the lifecycle. Two modules answering one action would 2162 + // make `ios_dispatch`'s first-match routing order-dependent, so the split 2163 + // is asserted from this side as well as that one. 1567 2164 for ([_][]const u8{ 1568 - "startLocationRecording", 1569 - "pauseLocationRecording", 1570 - "resumeLocationRecording", 1571 - "stopLocationRecording", 1572 - "getLocationRecordingState", 1573 2165 "readLocationRecording", 1574 2166 }) |action| { 1575 2167 try testing.expectError(