also looking at this
feat(ios): location recording moves to the manager it always shared — ratchet 23 -> 18
#125
6 files
+1653
-170
| @@ -457,7 +457,12 @@ struct CraftWebView: UIViewRepresentable { | ||
| 457 | 457 | locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
| 458 | 458 | locationManager?.activityType = .fitness |
| 459 | 459 | 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 | } | |
| 461 | 466 | } |
| 462 | 467 | if config.enableContacts { |
| 463 | 468 | contactStore = CNContactStore() |
| @@ -5465,6 +5470,7 @@ final class CraftZigRuntime: NSObject { | ||
| 5465 | 5470 | private typealias HandleActionFn = @convention(c) ( |
| 5466 | 5471 | UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64 |
| 5467 | 5472 | ) -> Bool |
| 5473 | private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool | |
| 5468 | 5474 | |
| 5469 | 5475 | private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW) |
| 5470 | 5476 | |
| @@ -5483,6 +5489,11 @@ final class CraftZigRuntime: NSObject { | ||
| 5483 | 5489 | return unsafeBitCast(sym, to: HandleActionFn.self) |
| 5484 | 5490 | }() |
| 5485 | 5491 | |
| 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 | ||
| 5486 | 5497 | /// Whether this build has a Zig runtime at all. Read by nothing here; kept |
| 5487 | 5498 | /// because "is Zig linked" is the first question to ask when an action |
| 5488 | 5499 | /// behaves like the Swift one after a migration was supposed to move it. |
| @@ -5497,6 +5508,24 @@ final class CraftZigRuntime: NSObject { | ||
| 5497 | 5508 | setWebViewFn?(Unmanaged.passUnretained(webView).toOpaque()) |
| 5498 | 5509 | } |
| 5499 | 5510 | |
| 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 | ||
| 5500 | 5529 | /// Forget this webview, if Zig is still holding it. |
| 5501 | 5530 | /// |
| 5502 | 5531 | /// `attach` hands over an unretained pointer, so the moment SwiftUI |
packages/zig/src/bridge_mobile_location.zigmodified+808-64
| @@ -94,26 +94,26 @@ | ||
| 94 | 94 | //! running. A watch that hits an error has no reply left to reject — its |
| 95 | 95 | //! `true` went out when it started — so the event is the only channel it has. |
| 96 | 96 | //! |
| 97 | //! **The recording side effect.** Swift's `didUpdateLocations` calls | |
| 97 | //! **The recording side effect, restored.** Swift's `didUpdateLocations` calls | |
| 98 | 98 | //! `appendRecordedLocation(data)` for *every* fix, including a one-shot |
| 99 | //! `requestLocation`, so calling `getCurrentPosition` during an active | |
| 100 | //! recording appends one extra sample to the track that `readLocationRecording` | |
| 101 | //! later returns. This module has its own `CLLocationManager`, so that sample | |
| 102 | //! is no longer added. Arguably the more correct behaviour — a one-shot query | |
| 103 | //! is not part of a route — but it *is* a behaviour change, and a page counting | |
| 104 | //! samples will see one fewer. | |
| 99 | //! `requestLocation`, so a `getCurrentPosition` during an active recording | |
| 100 | //! appends one extra sample to the track. While the recorder was on the shim | |
| 101 | //! and this module had a manager of its own, that sample was no longer added — | |
| 102 | //! a behaviour change a page counting samples could see. The recorder now runs | |
| 103 | //! on this manager, so the one-shot's fix reaches the track again, exactly as | |
| 104 | //! it does in the spec. | |
| 105 | 105 | //! |
| 106 | //! **One `craftLocationUpdate` per manager while a recording is running.** The | |
| 107 | //! six `*LocationRecording` actions stay with the shim | |
| 108 | //! (`bridge_mobile_locrecording.zig` serves only `readLocationRecording`), so a | |
| 109 | //! recording runs Swift's manager and Swift's `didUpdateLocations`, which fires | |
| 110 | //! `craftLocationUpdate` for every fix whenever `isRecordingLocation` is true — | |
| 111 | //! watch or no watch. A page that records *and* watches therefore has two | |
| 112 | //! managers feeding one event name, and its watch callback runs roughly twice | |
| 113 | //! as often as it did under a single shared manager. Every one of those events | |
| 114 | //! carries a real fix; none is fabricated and none is dropped. It is a | |
| 115 | //! duplication, it is visible to a page that counts samples, and it disappears | |
| 116 | //! when the recording actions migrate. | |
| 106 | //! **One `craftLocationUpdate` per fix, again.** A paragraph here used to | |
| 107 | //! record a duplication: the six `*LocationRecording` actions stayed with the | |
| 108 | //! shim, so a recording ran Swift's manager and Swift's `didUpdateLocations`, | |
| 109 | //! which fires `craftLocationUpdate` whenever `isRecordingLocation` is true — | |
| 110 | //! watch or no watch. A page that recorded *and* watched therefore had two | |
| 111 | //! managers feeding one event name and saw roughly two events per fix. It | |
| 112 | //! ended, as that paragraph predicted, when the recording actions migrated: | |
| 113 | //! five of the six now live in this file, on this manager, and the sixth reads | |
| 114 | //! a file. The emit guard below is Swift's own, | |
| 115 | //! `if isWatchingLocation || isRecordingLocation`, and one manager satisfies | |
| 116 | //! it once. | |
| 117 | 117 | //! |
| 118 | 118 | //! The converse loss is gone rather than added to: a `getCurrentPosition` |
| 119 | 119 | //! issued while *this* module's watch is running is broadcast to the watch |
| @@ -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; |
| @@ -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 |
| @@ -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. |
| @@ -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 | |
| @@ -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 | |
| @@ -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 | } |
| @@ -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. |
| @@ -565,8 +594,515 @@ 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 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. | |
| 821 | pub 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. | |
| 886 | fn 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. | |
| 915 | const 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. | |
| 924 | const 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 | } | |
| 568 | 944 | }; |
| 569 | 945 | |
| 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. | |
| 955 | const Authorize = enum { request, skip }; | |
| 956 | ||
| 957 | fn 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. | |
| 986 | const 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`. | |
| 993 | fn 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. | |
| 1027 | fn 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. | |
| 1048 | fn 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. | |
| 1068 | fn 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. | |
| 1081 | fn 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. | |
| 1102 | fn nowMillis() f64 { | |
| 1103 | return @floatFromInt(compat.milliTimestamp()); | |
| 1104 | } | |
| 1105 | ||
| 570 | 1106 | /// The answer for a full block pool, copied from `bridge_mobile_permissions`: |
| 571 | 1107 | /// `BridgeError` has no "Busy", INVALID_PARAMETER is the migration notes' |
| 572 | 1108 | /// designated stand-in, and the point is that the seventeenth concurrent call |
| @@ -1116,6 +1652,32 @@ var watch_sels: ?Sels = null; | ||
| 1116 | 1652 | /// not a guard. |
| 1117 | 1653 | var state_mutex: compat_mutex.Mutex = .{}; |
| 1118 | 1654 | |
| 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. | |
| 1668 | var 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. | |
| 1675 | var 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. | |
| 1679 | const recording_allocator = std.heap.c_allocator; | |
| 1680 | ||
| 1119 | 1681 | /// Record the call the delegate will answer, returning whatever it displaced so |
| 1120 | 1682 | /// the caller can reject that one rather than drop it. |
| 1121 | 1683 | fn publishPendingFix(ticket: ios_async.Ticket, sels: Sels) ?PendingFix { |
| @@ -1152,6 +1714,11 @@ const DelegateWork = struct { | ||
| 1152 | 1714 | /// until `clearWatch` stops it. Clearing it here would deliver one event |
| 1153 | 1715 | /// and then go silent with the page's callback still registered. |
| 1154 | 1716 | 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, | |
| 1155 | 1722 | }; |
| 1156 | 1723 | |
| 1157 | 1724 | fn takeDelegateWork() DelegateWork { |
| @@ -1159,7 +1726,26 @@ fn takeDelegateWork() DelegateWork { | ||
| 1159 | 1726 | defer state_mutex.unlock(); |
| 1160 | 1727 | const call = pending_fix; |
| 1161 | 1728 | 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. | |
| 1738 | fn 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. | |
| 1745 | fn watchHoldsTheManager() bool { | |
| 1746 | state_mutex.lock(); | |
| 1747 | defer state_mutex.unlock(); | |
| 1748 | return watch_sels != null; | |
| 1163 | 1749 | } |
| 1164 | 1750 | |
| 1165 | 1751 | /// What `clearWatch` is stopping. |
| @@ -1219,9 +1805,11 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void { | ||
| 1219 | 1805 | const sels = blk: { |
| 1220 | 1806 | if (work.pending) |call| break :blk call.sels; |
| 1221 | 1807 | if (work.watch) |watching| break :blk watching; |
| 1808 | if (work.recording) |collecting| break :blk collecting; | |
| 1222 | 1809 | // 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", .{}); | |
| 1225 | 1813 | return; |
| 1226 | 1814 | }; |
| 1227 | 1815 | |
| @@ -1246,7 +1834,25 @@ fn didUpdateLocations(_: Id, _: Id, _: Id, locations: Id) callconv(.c) void { | ||
| 1246 | 1834 | // to both. A page reading `e.detail.accuracy` and a page reading |
| 1247 | 1835 | // `position.accuracy` are reading the same eight keys. |
| 1248 | 1836 | 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); | |
| 1250 | 1856 | } |
| 1251 | 1857 | |
| 1252 | 1858 | fn 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 { | ||
| 1427 | 2033 | const testing = std.testing; |
| 1428 | 2034 | |
| 1429 | 2035 | test "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); | |
| 1431 | 2037 | try testing.expectEqualStrings(A.get_current_position, capability_actions[0].name); |
| 1432 | 2038 | try testing.expectEqualStrings(A.watch_position, capability_actions[1].name); |
| 1433 | 2039 | 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); | |
| 1434 | 2045 | |
| 1435 | 2046 | for (capability_actions) |decl| { |
| 1436 | 2047 | // 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" { | ||
| 1438 | 2049 | // nothing. Both failure modes are invisible from the page. Swift |
| 1439 | 2050 | // resolves all three of these, so all three are `.result` — including |
| 1440 | 2051 | // `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`. | |
| 1442 | 2054 | try testing.expectEqual(capabilities.Reply.result, decl.reply); |
| 1443 | 2055 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); |
| 1444 | 2056 | // `.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" { | ||
| 1561 | 2173 | bridge_error.BridgeError.UnknownAction, |
| 1562 | 2174 | bridge.handleMessage("getcurrentposition", "{}"), |
| 1563 | 2175 | ); |
| 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. | |
| 1567 | 2181 | for ([_][]const u8{ |
| 1568 | "startLocationRecording", | |
| 1569 | "pauseLocationRecording", | |
| 1570 | "resumeLocationRecording", | |
| 1571 | "stopLocationRecording", | |
| 1572 | "getLocationRecordingState", | |
| 1573 | 2182 | "readLocationRecording", |
| 1574 | 2183 | }) |action| { |
| 1575 | 2184 | try testing.expectError( |
| @@ -2266,3 +2875,138 @@ test "the delegate class registers under its own name, idempotently" { | ||
| 2266 | 2875 | return error.SelectorNotFound; |
| 2267 | 2876 | try testing.expect(!responds(first, sel_responds, sel_auth)); |
| 2268 | 2877 | } |
| 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. | |
| 2893 | fn resetRecordingForTest() void { | |
| 2894 | commitRecording(.{}, null) catch {}; | |
| 2895 | state_mutex.lock(); | |
| 2896 | defer state_mutex.unlock(); | |
| 2897 | watch_sels = null; | |
| 2898 | } | |
| 2899 | ||
| 2900 | test "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 | ||
| 2918 | test "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 | ||
| 2932 | test "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 | ||
| 2949 | test "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 | ||
| 2968 | test "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 | ||
| 2996 | test "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
| @@ -1,16 +1,15 @@ | ||
| 1 | //! The one location-recording action of the `mobile` namespace that Zig can | |
| 2 | //! serve honestly: `readLocationRecording`. | |
| 1 | //! The on-disk half of a location recording: the track, the state mirror, and | |
| 2 | //! the one action that only reads them — `readLocationRecording`. | |
| 3 | 3 | //! |
| 4 | //! `startLocationRecording`, `pauseLocationRecording`, `resumeLocationRecording`, | |
| 5 | //! `stopLocationRecording` and `getLocationRecordingState` are deliberately | |
| 6 | //! **absent** from the `A` block — not `.unavailable`, absent — so | |
| 7 | //! `ios_dispatch`'s first-match chain falls through to the Swift shim that | |
| 8 | //! serves all five correctly today. The last two sections give the full | |
| 9 | //! argument; the short version is that the recorder is not a CLLocationManager | |
| 10 | //! plus a buffer, it is a CLLocationManager plus two files that | |
| 11 | //! `CraftWebView.Coordinator.init` re-adopts on every launch, and a Zig-owned | |
| 12 | //! `stop` cannot stop a recording Swift has already restored into its own | |
| 13 | //! process state. | |
| 4 | //! The other five live in `bridge_mobile_location.zig`, with the | |
| 5 | //! `CLLocationManager` they share. That split follows the spec rather than | |
| 6 | //! cutting across it: in `CraftWebView.Coordinator` a recording is not a device | |
| 7 | //! of its own, it is a flag on the one delegate plus these two files, and the | |
| 8 | //! flag belongs next to the delegate while the files belong here. | |
| 9 | //! | |
| 10 | //! This module keeps no manager, registers no Objective-C class and emits on no | |
| 11 | //! channel. It does now *write*, which it did not when it only read the track, | |
| 12 | //! so the format is stated once here and used from both sides. | |
| 14 | 13 | //! |
| 15 | 14 | //! One JS surface reaches these six, `CraftApp.swift:2379-2384`: |
| 16 | 15 | //! |
| @@ -127,86 +126,61 @@ | ||
| 127 | 126 | //! recording, and the alternative — a track silently cut off at 32 MiB and |
| 128 | 127 | //! reported as the whole one — is the failure this module exists to avoid. |
| 129 | 128 | //! |
| 130 | //! ## Why the other five are absent rather than `.unavailable` | |
| 131 | //! | |
| 132 | //! `CraftWebView.makeCoordinator()` builds the `Coordinator` specifically so | |
| 133 | //! the shim has a host (`CraftSwiftShim.coordinator = coordinator`, | |
| 134 | //! `CraftApp.swift:371`), and `Coordinator.init` calls | |
| 135 | //! `restoreLocationRecordingState()` at line 447 whenever | |
| 136 | //! `config.enableGeolocation`. **That happens on every launch no matter who | |
| 137 | //! serves these actions.** So: | |
| 138 | //! | |
| 139 | //! - A Zig `start` must persist `{"active":true,…}` or the recording dies at | |
| 140 | //! app termination while Swift's survives. Having persisted it, the next | |
| 141 | //! launch has Swift restore it: its own `isRecordingLocation = true`, its | |
| 142 | //! own manager, its own `startUpdatingLocation()`, appending to the same | |
| 143 | //! `.jsonl`. | |
| 144 | //! - A later Zig `stop` can flip the file to `active:false` and stop *Zig's* | |
| 145 | //! manager. It cannot clear Swift's in-memory `isRecordingLocation` or stop | |
| 146 | //! Swift's manager: `CraftSwiftShim.coordinator` is a Swift `static weak | |
| 147 | //! var` with no `@objc` surface, and `ios_dispatch.route` only reaches the | |
| 148 | //! shim for actions **no** module claims. The page's promise resolves | |
| 149 | //! `active:false` while GPS keeps running and keeps writing fixes to disk — | |
| 150 | //! fabricated success on a location-privacy operation, invisible from Zig. | |
| 151 | //! - Refusing to persist `active:true` avoids the hijack and silently loses | |
| 152 | //! the recording on termination instead, which is the exact failure the | |
| 153 | //! feature exists to prevent. | |
| 154 | //! | |
| 155 | //! `getLocationRecordingState` has a second, independent blocker: Swift answers | |
| 156 | //! it from **memory**, and `restoreLocationRecordingState`'s | |
| 157 | //! `guard state["active"] as? Bool == true else { return }` leaves | |
| 158 | //! `locationRecordingId`/`startedAt` nil after any relaunch that follows a | |
| 159 | //! stop, while the state file still holds the stopped recording's `id` and | |
| 160 | //! `startedAt`. A file-reading Zig would answer `{"id":"ABC-…","startedAt":169…}` | |
| 161 | //! where Swift answers `{"id":null,"startedAt":null}` — a guaranteed, | |
| 162 | //! reproducible divergence, made worse by `persistLocationRecordingState` | |
| 163 | //! swallowing its own write failures so memory and file can drift with nothing | |
| 164 | //! to signal it. | |
| 129 | //! ## What it took to move the other five, and what had gone stale | |
| 165 | 130 | //! |
| 166 | //! And `startLocationRecording` is the only one of the six behind | |
| 167 | //! `config.enableGeolocation` (`CraftApp.swift:635-640`), a flag with no mirror | |
| 168 | //! anywhere in `packages/zig/src` — `ios.zig`'s `AppConfig` has no such field, | |
| 169 | //! the precedent and its consequences being written out at | |
| 170 | //! `bridge_mobile_system.zig:39-43` for `enableShare`. Serving it ungated means | |
| 171 | //! turning on GPS and writing a location track to disk in an app that | |
| 172 | //! explicitly disabled geolocation, which is a materially larger divergence | |
| 173 | //! than an unexpected share sheet. | |
| 131 | //! This section used to argue that the five could not move. Two of its three | |
| 132 | //! blockers had already been removed by other work and nothing re-checked them; | |
| 133 | //! the audit is #124, and the short version is worth keeping because the shape | |
| 134 | //! of the mistake is more useful than the conclusion was. | |
| 174 | 135 | //! |
| 175 | //! Per the migration's standing rule, falling through beats `.unavailable`: | |
| 176 | //! `.unavailable` would make Zig dispatch and *refuse* five actions that work | |
| 177 | //! end-to-end today, including the capability gate, the authorization mode, | |
| 178 | //! background continuation, atomic persistence, file protection, backup | |
| 179 | //! exclusion and relaunch restore. Absence keeps them working. | |
| 136 | //! - **"`enableGeolocation` has no mirror anywhere in `packages/zig/src`."** | |
| 137 | //! True when written. `ios_config.zig` now reads the same | |
| 138 | //! `craft.config.json` Swift decodes and exposes that flag, and its own | |
| 139 | //! header names this note as one of the two it closes. Only | |
| 140 | //! `startLocationRecording` is gated in the spec, so it is the only one of | |
| 141 | //! the five in `ios_config.gateFor`. | |
| 142 | //! - **"It needs an event channel that does not exist."** The paragraph | |
| 143 | //! checked `capabilities.Channel`, where `.location_update` really is | |
| 144 | //! `"craft:location:update"` and really is listened for by nothing on iOS. | |
| 145 | //! But the iOS event path is `ios_events.Event`, whose `.location_update` | |
| 146 | //! is `"craftLocationUpdate"` — the name the recorder needs — and | |
| 147 | //! `bridge_mobile_location.zig` was already emitting on it for | |
| 148 | //! `watchPosition`, one module away. | |
| 149 | //! - **"`getLocationRecordingState` answers from memory, and a file-reading | |
| 150 | //! Zig would diverge."** This one was correct, and it is the reason | |
| 151 | //! `decodeState` exists in the shape it does. Swift's restore guard, | |
| 152 | //! `state["active"] as? Bool == true`, means a *stopped* recording — whose | |
| 153 | //! file still carries the old `id` and `startedAt` — restores as nothing at | |
| 154 | //! all. Ported literally, so a stop-then-read in one launch still reports | |
| 155 | //! the id and a relaunch still reports null. | |
| 180 | 156 | //! |
| 181 | //! Migrating them honestly means deleting the recorder from `CraftApp.swift` in | |
| 182 | //! the same change — the six `case` arms (635-650), the six handlers | |
| 183 | //! (2960-3028), the file helpers (2904-2911, 2927-2958, 3039-3068), the four | |
| 184 | //! state vars (392-395) and the `restoreLocationRecordingState()` call at 447 — | |
| 185 | //! and untangling `isRecordingLocation` from `stopWatchingPosition` and from | |
| 186 | //! `didUpdateLocations`'s emit guard. It also needs an event channel that does | |
| 187 | //! not exist: the recorder's live samples go out as **`craftLocationUpdate`** | |
| 188 | //! (`CraftApp.swift:3090`, listeners at 1648 and 2362), and | |
| 189 | //! `capabilities.Channel` has no member for that name — | |
| 190 | //! `.location_update` is `"craft:location:update"`, which no iOS page listens | |
| 191 | //! for. Any partial split of this state across the two languages reintroduces | |
| 192 | //! the hijack above, so it is not an incremental move. | |
| 157 | //! The fourth obstacle was real and was never written down here: SwiftUI runs | |
| 158 | //! `makeCoordinator()` before `makeUIView`, so `Coordinator.init` — and | |
| 159 | //! `restoreLocationRecordingState()` inside it — ran before | |
| 160 | //! `craft_ios_set_webview` had been called even once. Whichever runtime | |
| 161 | //! restores first owns the manager for the rest of the launch. That is settled | |
| 162 | //! by `craft_ios_adopt_location_recording`, which Swift consults *instead of* | |
| 163 | //! its own restore and falls back from when `dlsym` finds no runtime. | |
| 193 | 164 | //! |
| 194 | 165 | //! ## No delegate, no manager, no event, no async |
| 195 | 166 | //! |
| 196 | //! This module reads one file and replies. It registers no Objective-C class, | |
| 167 | //! This module reads and writes two files. It registers no Objective-C class, | |
| 197 | 168 | //! keeps no delegate alive, touches no `CLLocationManager`, emits on no |
| 198 | //! channel, and takes no `ios_async` ticket: `loadRecordedLocations()` is | |
| 199 | //! synchronous and so is this. `ios_dispatch.handleMessage` runs from | |
| 169 | //! channel, and takes no `ios_async` ticket: every one of these operations is | |
| 170 | //! synchronous, as `loadRecordedLocations()` and | |
| 171 | //! `persistLocationRecordingState()` both are. `ios_dispatch.handleMessage` runs from | |
| 200 | 172 | //! `craftDidReceiveScriptMessage`, a `WKScriptMessageHandler` callback WebKit |
| 201 | 173 | //! delivers on the main thread, so the reply is sent from the main thread with |
| 202 | 174 | //! no hop — the reasoning is written out at `bridge_mobile_display.zig`. |
| 203 | 175 | //! |
| 204 | 176 | //! No mutex either, and that is not an oversight. The premise of a |
| 205 | 177 | //! mutex-guarded in-memory buffer does not match this design: the writer is |
| 206 | //! `appendRecordedLocation`, called from `locationManager(_:didUpdateLocations:)`, | |
| 207 | //! which CoreLocation delivers on the thread that created the manager — the | |
| 208 | //! main thread — and the reader is this dispatch, also on the main thread. | |
| 209 | //! The shared state is a file, and the two never run concurrently. | |
| 178 | //! `appendSample`, called from `didUpdateLocations`, which CoreLocation | |
| 179 | //! delivers on the thread that created the manager — the main thread — and the | |
| 180 | //! readers are dispatches, also on the main thread. The shared state is a file, | |
| 181 | //! and the two never run concurrently. The recorder's *in-memory* state does | |
| 182 | //! take a mutex, and it takes it where it lives, in | |
| 183 | //! `bridge_mobile_location.zig`. | |
| 210 | 184 | |
| 211 | 185 | const std = ; |
| 212 | 186 | const builtin = ; |
| @@ -231,10 +205,12 @@ const Id = ?*anyopaque; | ||
| 231 | 205 | /// `test/ios_conformance_test.zig` matches the two lists by string in both |
| 232 | 206 | /// directions. |
| 233 | 207 | /// |
| 234 | /// The other five recording actions are absent on purpose — the module | |
| 235 | /// comment's second-to-last section gives the full argument. An action listed | |
| 236 | /// here is an action this module takes away from the Swift shim, and that trade | |
| 237 | /// is only worth making when Zig's answer is at least as good. | |
| 208 | /// The other five are served by `bridge_mobile_location.zig`, which owns the | |
| 209 | /// `CLLocationManager` they share with the watch. They are not absent from the | |
| 210 | /// migration, only from this file — and `bridge_mobile_location.zig`'s own | |
| 211 | /// routing test asserts this module does not answer them, so the split cannot | |
| 212 | /// quietly become an overlap that makes `ios_dispatch`'s first-match order | |
| 213 | /// significant. | |
| 238 | 214 | pub const A = struct { |
| 239 | 215 | pub const read_location_recording = "readLocationRecording"; |
| 240 | 216 | }; |
| @@ -588,6 +564,503 @@ fn readTrackBytes(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { | ||
| 588 | 564 | return buf; |
| 589 | 565 | } |
| 590 | 566 | |
| 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`. | |
| 583 | const state_file_name = "craft-location-recording-state.json"; | |
| 584 | ||
| 585 | /// The absolute path of `<AppSupport>/craft-location-recording-state.json`. | |
| 586 | fn 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. | |
| 596 | const 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()`. | |
| 605 | pub 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. | |
| 635 | pub 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`. | |
| 643 | fn 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. | |
| 661 | fn 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. | |
| 719 | pub 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. | |
| 731 | fn 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. | |
| 773 | pub 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. | |
| 791 | pub 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. | |
| 798 | fn 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 `[]`. | |
| 838 | pub 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. | |
| 855 | pub 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. | |
| 862 | fn 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. | |
| 876 | fn 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. | |
| 926 | fn 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. | |
| 967 | fn 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`. | |
| 985 | fn 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`. | |
| 1013 | fn 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. | |
| 1041 | fn 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 | ||
| 1047 | extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; | |
| 1048 | ||
| 1049 | /// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin. | |
| 1050 | const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); | |
| 1051 | ||
| 1052 | /// An autoreleased `NSString` for a Zig slice, via `stringWithUTF8String:`. | |
| 1053 | fn 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 | ||
| 591 | 1064 | // ============================================================================= |
| 592 | 1065 | // Tests. |
| 593 | 1066 | // |
| @@ -662,18 +1135,15 @@ test "every route the dispatcher has is a declared action" { | ||
| 662 | 1135 | } |
| 663 | 1136 | } |
| 664 | 1137 | |
| 665 | test "the five stateful recording actions are left to the Swift shim" { | |
| 666 | // The deliberate omissions, asserted so they cannot be "fixed" by accident. | |
| 1138 | test "the five stateful recording actions belong to the manager's module, not this one" { | |
| 1139 | // Not a deferral any more — an ownership boundary, asserted from this side | |
| 1140 | // so it cannot quietly become an overlap. | |
| 667 | 1141 | // |
| 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. | |
| 677 | 1147 | const unserved = [_][]const u8{ |
| 678 | 1148 | "startLocationRecording", |
| 679 | 1149 | "pauseLocationRecording", |
| @@ -1182,3 +1652,224 @@ test "with no recording on the host, the handler answers rather than failing" { | ||
| 1182 | 1652 | |
| 1183 | 1653 | try bridge.handleMessage(A.read_location_recording, "{}"); |
| 1184 | 1654 | } |
| 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 | ||
| 1666 | const test_state_name = "craft-locrecording-test-state.json"; | |
| 1667 | ||
| 1668 | fn 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 | ||
| 1675 | fn 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 | ||
| 1681 | test "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 | ||
| 1698 | test "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 | ||
| 1710 | test "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 | ||
| 1738 | test "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 | ||
| 1752 | test "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 | ||
| 1766 | test "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 | ||
| 1775 | test "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 | ||
| 1793 | test "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 | ||
| 1806 | test "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 | ||
| 1815 | test "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 | ||
| 1833 | test "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 | ||
| 1844 | test "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 | ||
| 1872 | test "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 | } | |
| @@ -448,6 +448,12 @@ pub fn gateFor(action: []const u8) ?Feature { | ||
| 448 | 448 | .{ "takeScreenshot", .screen_capture }, |
| 449 | 449 | .{ "unlockOrientation", .orientation_lock }, |
| 450 | 450 | .{ "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 }, | |
| 451 | 457 | }; |
| 452 | 458 | inline for (table) |entry| { |
| 453 | 459 | if (std.mem.eql(u8, action, entry[0])) return entry[1]; |
| @@ -475,6 +475,28 @@ export fn craft_ios_set_webview(webview: ?*anyopaque) callconv(.c) void { | ||
| 475 | 475 | setWebView(webview); |
| 476 | 476 | } |
| 477 | 477 | |
| 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. | |
| 496 | export fn craft_ios_adopt_location_recording() callconv(.c) bool { | |
| 497 | return bridge_mobile_location.adoptRecording(); | |
| 498 | } | |
| 499 | ||
| 478 | 500 | /// Forget the webview, if it is still the one the caller installed. |
| 479 | 501 | /// |
| 480 | 502 | /// The other half of `craft_ios_set_webview`, and the reason it is needed: |
| @@ -121,7 +121,7 @@ const dispatch_end = "func webView("; | ||
| 121 | 121 | /// and `deliverErrorCode` landed the day after that was written. Worth |
| 122 | 122 | /// re-reading the other deferrals for the same reason before assuming they |
| 123 | 123 | /// still hold. |
| 124 | const max_not_yet_migrated: usize = 23; | |
| 124 | const max_not_yet_migrated: usize = 18; | |
| 125 | 125 | |
| 126 | 126 | fn dispatcherRegion() []const u8 { |
| 127 | 127 | const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return ""; |
| @@ -305,15 +305,6 @@ const deliberate_deferrals = [_]Deferral{ | ||
| 305 | 305 | // name the spec is free to change. |
| 306 | 306 | .{ .action = "registerPush", .reason = "the device token lands on a SwiftUI-owned app delegate; Zig could only observe a notification Swift posts" }, |
| 307 | 307 | |
| 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 | ||
| 317 | 308 | // VisionKit's `DataScannerViewController` is Swift-only: the framework's |
| 318 | 309 | // Objective-C headers carry DocumentCamera and nothing else, and the |
| 319 | 310 | // delegate callback takes a `RecognizedItem`, a Swift enum with associated |
| @@ -356,7 +347,7 @@ test "every recorded deferral is real, and still a deferral" { | ||
| 356 | 347 | } |
| 357 | 348 | |
| 358 | 349 | // 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); | |
| 360 | 351 | |
| 361 | 352 | // As of this commit the table happens to cover every unmigrated action, |
| 362 | 353 | // but that is not asserted. Pinning the exact count would make migrating a |