@craft-native/ios 0.0.237 → 0.0.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -421,6 +421,12 @@ class CraftEventManager {
421
421
  }
422
422
  }
423
423
 
424
+ /// Zig already delivers to the page; native-only delivery must not repeat it.
425
+ func sendToAppScript(_ event: String, data: [String: Any]) {
426
+ guard listener == .appScript else { return }
427
+ sendToWeb(event, data: data)
428
+ }
429
+
424
430
  /// An event no one will hear: logged, and background work it holds is
425
431
  /// answered at once.
426
432
  private func drop(_ event: String, data: [String: Any]) {
@@ -613,6 +619,12 @@ struct CraftApp: App {
613
619
  // images and all, and a tab bar or a splash that waited for it would be late
614
620
  // on every launch.
615
621
 
622
+ /// Finger tracking changes only the indicator. Publishing it on CraftChrome
623
+ /// invalidated the glass surface, every tab and the splash for every pan event.
624
+ final class CraftTabSwipeProgress: ObservableObject {
625
+ @Published var position: CGFloat?
626
+ }
627
+
616
628
  final class CraftChrome: ObservableObject {
617
629
  static let shared = CraftChrome()
618
630
 
@@ -625,6 +637,21 @@ final class CraftChrome: ObservableObject {
625
637
  var titleOverride: String? = nil
626
638
  var iconTint: String? = nil
627
639
  var iconPulse = false
640
+ var selectedSymbol: String? = nil
641
+ var selectedIconTint: String? = nil
642
+ var selectedIconPulse: Bool? = nil
643
+
644
+ static func normalizedSelectedSymbol(_ value: String?) -> String? {
645
+ guard let name = value?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil }
646
+ return name
647
+ }
648
+
649
+ func iconAppearance(selected: Bool, motionAllowed: Bool) -> (symbol: String, tint: String?, pulse: Bool) {
650
+ let name = selected ? selectedSymbol ?? symbol : symbol
651
+ let tint = selected ? selectedIconTint ?? iconTint : iconTint
652
+ let pulse = selected ? selectedIconPulse ?? iconPulse : iconPulse
653
+ return (name, tint, pulse && motionAllowed)
654
+ }
628
655
  var displayTitle: String { titleOverride ?? title }
629
656
  var accessibilityLabel: String? = nil
630
657
  var image: String? = nil
@@ -633,7 +660,11 @@ final class CraftChrome: ObservableObject {
633
660
  @Published var tabs: [Tab] = []
634
661
  @Published var tabAccessory: Tab?
635
662
  @Published var selected: String?
636
- @Published private(set) var tabSwipePosition: CGFloat?
663
+ let tabSwipeProgress = CraftTabSwipeProgress()
664
+ private(set) var tabSwipePosition: CGFloat? {
665
+ get { tabSwipeProgress.position }
666
+ set { tabSwipeProgress.position = newValue }
667
+ }
637
668
  @Published var tabBarVisible = false
638
669
  @Published var splashVisible = true
639
670
  @Published var tint: UIColor?
@@ -675,7 +706,7 @@ final class CraftChrome: ObservableObject {
675
706
  splashVisible = splash
676
707
  tabSwipeEnabled = true
677
708
  guard !configured.isEmpty else { return }
678
- tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
709
+ tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
679
710
  self.selected = selected ?? configured.first?.id
680
711
  tabBarVisible = true
681
712
  }
@@ -688,27 +719,42 @@ final class CraftChrome: ObservableObject {
688
719
  /// Change one tab's caption or icon without replacing stacks or selection.
689
720
  func updateTab(_ item: [String: Any]) {
690
721
  guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
722
+ // One live reading is one change. Mutating a member of a Published
723
+ // array sends objectWillChange for every field, including equal ones,
724
+ // rebuilding the glass bar repeatedly while its content scrolls.
725
+ var next = tabs[index]
691
726
  if item.keys.contains("iconText") {
692
- tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
727
+ next.iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
693
728
  }
694
729
  if item.keys.contains("title") {
695
- tabs[index].titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
730
+ next.titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
696
731
  }
697
732
  if item.keys.contains("iconTint") {
698
- tabs[index].iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
733
+ next.iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
699
734
  }
700
735
  if item.keys.contains("iconPulse") {
701
- tabs[index].iconPulse = item["iconPulse"] as? Bool ?? false
736
+ next.iconPulse = item["iconPulse"] as? Bool ?? false
737
+ }
738
+ if item.keys.contains("selectedSymbol") {
739
+ next.selectedSymbol = Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String)
740
+ }
741
+ if item.keys.contains("selectedIconTint") {
742
+ next.selectedIconTint = (item["selectedIconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
743
+ }
744
+ if item.keys.contains("selectedIconPulse") {
745
+ next.selectedIconPulse = item["selectedIconPulse"] as? Bool
702
746
  }
703
747
  if item.keys.contains("accessibilityLabel") {
704
- tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
748
+ next.accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
705
749
  }
706
750
  if item.keys.contains("image") {
707
- tabs[index].image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
751
+ next.image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
708
752
  }
709
753
  if item.keys.contains("badge") {
710
- tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
754
+ next.badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
711
755
  }
756
+ guard next != tabs[index] else { return }
757
+ tabs[index] = next
712
758
  }
713
759
  /// A destination beside the tab pill, outside the tab stacks and swipe order.
714
760
  func updateTabAccessory(_ item: [String: Any]?) {
@@ -752,7 +798,10 @@ final class CraftChrome: ObservableObject {
752
798
  tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
753
799
  guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
754
800
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
755
- return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
801
+ return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge,
802
+ selectedSymbol: Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String),
803
+ selectedIconTint: (item["selectedIconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 },
804
+ selectedIconPulse: item["selectedIconPulse"] as? Bool)
756
805
  }
757
806
  if let selected = body["selected"] as? String {
758
807
  self.selected = selected
@@ -1108,18 +1157,7 @@ struct CraftTabBarView: View {
1108
1157
  }
1109
1158
  }
1110
1159
  .background {
1111
- GeometryReader { geometry in
1112
- if let position = chrome.tabSwipePosition
1113
- ?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
1114
- let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
1115
- Capsule()
1116
- .fill(Color.primary.opacity(0.09))
1117
- .frame(width: width, height: geometry.size.height)
1118
- .offset(x: position * width)
1119
- }
1120
- }
1121
- .allowsHitTesting(false)
1122
- .accessibilityHidden(true)
1160
+ CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
1123
1161
  }
1124
1162
  }
1125
1163
 
@@ -1129,13 +1167,15 @@ struct CraftTabBarView: View {
1129
1167
 
1130
1168
  /// Live styling belongs to the icon, leaving selection and the caption clear.
1131
1169
  @ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
1132
- let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
1133
- let icon = Image(systemName: symbol(tab.symbol, selected: selected))
1170
+ let appearance = tab.iconAppearance(selected: selected, motionAllowed: !reduceMotion && scenePhase == .active && chrome.tabBarVisible)
1171
+ let color = appearance.tint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
1172
+ let name = selected && tab.selectedSymbol != nil ? appearance.symbol : symbol(appearance.symbol, selected: selected)
1173
+ let icon = Image(systemName: name)
1134
1174
  .font(.system(size: iconSize, weight: selected ? .semibold : .regular))
1135
1175
  .foregroundStyle(color)
1136
1176
  .frame(height: iconSize + 6)
1137
1177
  if #available(iOS 17.0, *) {
1138
- icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
1178
+ icon.symbolEffect(.pulse, options: .repeating, isActive: appearance.pulse)
1139
1179
  } else {
1140
1180
  icon
1141
1181
  }
@@ -1148,6 +1188,26 @@ struct CraftTabBarView: View {
1148
1188
  }
1149
1189
  }
1150
1190
 
1191
+ private struct CraftTabSelectionIndicator: View {
1192
+ @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1193
+ let selected: Int?
1194
+ let count: Int
1195
+
1196
+ var body: some View {
1197
+ GeometryReader { geometry in
1198
+ if let position = progress.position ?? selected.map({ CGFloat($0) }) {
1199
+ let width = geometry.size.width / CGFloat(max(1, count))
1200
+ Capsule()
1201
+ .fill(Color.primary.opacity(0.09))
1202
+ .frame(width: width, height: geometry.size.height)
1203
+ .offset(x: position * width)
1204
+ }
1205
+ }
1206
+ .allowsHitTesting(false)
1207
+ .accessibilityHidden(true)
1208
+ }
1209
+ }
1210
+
1151
1211
  /// The launch screen, held until the page is ready: the same colour and the
1152
1212
  /// same logo at the same place, so the hand-over from the system's launch
1153
1213
  /// screen cannot be seen.
@@ -1517,6 +1577,9 @@ struct CraftConfig: Codable {
1517
1577
  var id: String
1518
1578
  var title: String
1519
1579
  var symbol: String? = nil
1580
+ var selectedSymbol: String? = nil
1581
+ var selectedIconTint: String? = nil
1582
+ var selectedIconPulse: Bool? = nil
1520
1583
  }
1521
1584
 
1522
1585
  struct BackgroundRefresh: Codable {
@@ -1935,6 +1998,8 @@ struct CraftWebView: UIViewRepresentable {
1935
1998
  private var isLocationRecordingPaused = false
1936
1999
  private var locationRecordingId: String?
1937
2000
  private var locationRecordingStartedAt: TimeInterval?
2001
+ private let recordingDirectory: URL?
2002
+ private var locationRecordingRestoreError: Error?
1938
2003
 
1939
2004
  // Network monitoring
1940
2005
  private var networkMonitor: NWPathMonitor?
@@ -2000,7 +2065,8 @@ struct CraftWebView: UIViewRepresentable {
2000
2065
  // SQLite database
2001
2066
  private var db: OpaquePointer?
2002
2067
 
2003
- init(config: CraftConfig) {
2068
+ init(config: CraftConfig, recordingDirectory: URL? = nil) {
2069
+ self.recordingDirectory = recordingDirectory
2004
2070
  self.config = config
2005
2071
  super.init()
2006
2072
  if config.enableSpeechRecognition {
@@ -2015,7 +2081,8 @@ struct CraftWebView: UIViewRepresentable {
2015
2081
  // relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
2016
2082
  // Running both would leave two managers appending to one track.
2017
2083
  if !CraftZigRuntime.adoptLocationRecording() {
2018
- restoreLocationRecordingState()
2084
+ do { try restoreLocationRecordingState() }
2085
+ catch { locationRecordingRestoreError = error }
2019
2086
  }
2020
2087
  }
2021
2088
  if config.enableContacts {
@@ -2165,6 +2232,13 @@ struct CraftWebView: UIViewRepresentable {
2165
2232
  /// `craft_ios_deliver_result` instead of evaluating JavaScript here —
2166
2233
  /// one reply path, owned by whichever side received the page's message.
2167
2234
  func dispatch(action: String, body: [String: Any], callbackId: String?) {
2235
+ if ["startLocationRecording", "pauseLocationRecording", "resumeLocationRecording", "stopLocationRecording", "getLocationRecordingState", "readLocationRecording", "acknowledgeLocationRecording"].contains(action),
2236
+ locationRecordingRestoreError != nil {
2237
+ rejectCallback(callbackId, error: "Unable to restore recording data", code: "RECORDING_STORAGE_ERROR")
2238
+ return
2239
+ }
2240
+ if ["getLocationRecordingState", "readLocationRecording", "stopLocationRecording"].contains(action),
2241
+ archivedLocationAction(action, body: body, callbackId: callbackId) { return }
2168
2242
  if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
2169
2243
  switch answer {
2170
2244
  case .success(let value): resolveCallback(callbackId, result: value)
@@ -2361,20 +2435,22 @@ struct CraftWebView: UIViewRepresentable {
2361
2435
  resolveCallback(callbackId, result: true)
2362
2436
  case "startLocationRecording":
2363
2437
  if config.enableGeolocation {
2364
- startLocationRecording(callbackId: callbackId)
2438
+ startLocationRecording(body: body, callbackId: callbackId)
2365
2439
  } else {
2366
2440
  rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
2367
2441
  }
2368
2442
  case "pauseLocationRecording":
2369
- pauseLocationRecording(callbackId: callbackId)
2443
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
2370
2444
  case "resumeLocationRecording":
2371
- resumeLocationRecording(callbackId: callbackId)
2445
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { resumeLocationRecording(callbackId: callbackId) }
2372
2446
  case "stopLocationRecording":
2373
- stopLocationRecording(callbackId: callbackId)
2447
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { stopLocationRecording(callbackId: callbackId) }
2374
2448
  case "getLocationRecordingState":
2375
2449
  getLocationRecordingState(callbackId: callbackId)
2376
2450
  case "readLocationRecording":
2377
- readLocationRecording(callbackId: callbackId)
2451
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
2452
+ case "acknowledgeLocationRecording":
2453
+ _ = archivedLocationAction(action, body: body, callbackId: callbackId)
2378
2454
  // App Badge
2379
2455
  case "setBadge":
2380
2456
  if let count = body["count"] as? Int {
@@ -3682,6 +3758,9 @@ struct CraftWebView: UIViewRepresentable {
3682
3758
  pushNotifications: \(config.enablePushNotifications),
3683
3759
  secureStorage: \(config.enableSecureStorage),
3684
3760
  geolocation: \(config.enableGeolocation),
3761
+ crypto: true,
3762
+ locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
3763
+ locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
3685
3764
  backgroundLocation: \(config.enableBackgroundLocation),
3686
3765
  healthKit: \(config.enableHealthKit),
3687
3766
  liveActivities: \(config.enableLiveActivities),
@@ -3698,6 +3777,8 @@ struct CraftWebView: UIViewRepresentable {
3698
3777
  brightness: true,
3699
3778
  music: \(config.enableAppleMusic),
3700
3779
  heartRate: \(config.enableHeartRate),
3780
+ heartRateRecordingOwnership: \(config.enableHeartRate),
3781
+ heartRateRecordingIntentIdentity: \(config.enableHeartRate),
3701
3782
  scheduledWorkouts: \(config.enableScheduledWorkouts),
3702
3783
  speech: true,
3703
3784
  crashReporter: \(CraftCrashReporter.shared.enabled),
@@ -5282,6 +5363,20 @@ struct CraftWebView: UIViewRepresentable {
5282
5363
  browserOpen: true
5283
5364
  };
5284
5365
  craft._invoke = function(action, payload) {
5366
+ var recordingOptions = action === 'heartRate' && payload && ['startRecording', 'pauseRecording', 'resumeRecording', 'stopRecording', 'readRecording', 'getRecordingState', 'acknowledgeRecording'].indexOf(payload.method) !== -1
5367
+ ? payload.options : ['startLocationRecording', 'pauseLocationRecording', 'resumeLocationRecording', 'stopLocationRecording', 'readLocationRecording', 'getLocationRecordingState', 'acknowledgeLocationRecording'].indexOf(action) !== -1 ? payload : null;
5368
+ if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'recordingId') &&
5369
+ (typeof recordingOptions.recordingId !== 'string' || !recordingOptions.recordingId.trim())) {
5370
+ var invalidStart = new Error('recordingId must be a nonempty recording ID');
5371
+ invalidStart.code = 'INVALID_ARGUMENT';
5372
+ return Promise.reject(invalidStart);
5373
+ }
5374
+ if (recordingOptions && Object.prototype.hasOwnProperty.call(recordingOptions, 'expectedId') &&
5375
+ (typeof recordingOptions.expectedId !== 'string' || !recordingOptions.expectedId.trim())) {
5376
+ var invalid = new Error('expectedId must be a nonempty recording ID');
5377
+ invalid.code = 'INVALID_ARGUMENT';
5378
+ return Promise.reject(invalid);
5379
+ }
5285
5380
  var self = craft;
5286
5381
  var id = 'cb_' + (++self._callbackId);
5287
5382
  var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
@@ -5419,6 +5514,7 @@ struct CraftWebView: UIViewRepresentable {
5419
5514
  legacyLocationWatchCallbacks.length = 0;
5420
5515
  return stopLocationWatchIfUnused();
5421
5516
  };
5517
+ craft.crypto = { randomUUID: function() { return craft._invoke('cryptoRandomUUID'); } };
5422
5518
  craft.location = {
5423
5519
  getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
5424
5520
  watchPosition: function(callback) {
@@ -5437,12 +5533,13 @@ struct CraftWebView: UIViewRepresentable {
5437
5533
  if (!locationWatchCallbacks.delete(id)) return;
5438
5534
  void stopLocationWatchIfUnused().catch(function() {});
5439
5535
  },
5440
- startRecording: function() { return craft._invoke('startLocationRecording'); },
5441
- pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
5442
- resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
5443
- stopRecording: function() { return craft._invoke('stopLocationRecording'); },
5444
- getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
5445
- readRecording: function() { return craft._invoke('readLocationRecording'); }
5536
+ startRecording: function(options) { return craft._invoke('startLocationRecording', options || {}); },
5537
+ pauseRecording: function(options) { return craft._invoke('pauseLocationRecording', options || {}); },
5538
+ resumeRecording: function(options) { return craft._invoke('resumeLocationRecording', options || {}); },
5539
+ stopRecording: function(options) { return craft._invoke('stopLocationRecording', options || {}); },
5540
+ getRecordingState: function(options) { return craft._invoke('getLocationRecordingState', options || {}); },
5541
+ acknowledgeRecording: function(options) { return craft._invoke('acknowledgeLocationRecording', options || {}); },
5542
+ readRecording: function(options) { return craft._invoke('readLocationRecording', options || {}); }
5446
5543
  };
5447
5544
  craft.health = {
5448
5545
  requestAuthorization: function(types, options) {
@@ -5530,12 +5627,13 @@ struct CraftWebView: UIViewRepresentable {
5530
5627
  connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
5531
5628
  reconnect: function() { return heartRate('reconnect'); },
5532
5629
  disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
5533
- startRecording: function() { return heartRate('startRecording'); },
5534
- pauseRecording: function() { return heartRate('pauseRecording'); },
5535
- resumeRecording: function() { return heartRate('resumeRecording'); },
5536
- stopRecording: function() { return heartRate('stopRecording'); },
5537
- getRecordingState: function() { return heartRate('getRecordingState'); },
5538
- readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
5630
+ startRecording: function(options) { return heartRate('startRecording', options || {}); },
5631
+ pauseRecording: function(options) { return heartRate('pauseRecording', options || {}); },
5632
+ resumeRecording: function(options) { return heartRate('resumeRecording', options || {}); },
5633
+ stopRecording: function(options) { return heartRate('stopRecording', options || {}); },
5634
+ getRecordingState: function(options) { return heartRate('getRecordingState', options || {}); },
5635
+ acknowledgeRecording: function(options) { return heartRate('acknowledgeRecording', options || {}); },
5636
+ readRecording: function(options) { return heartRate('readRecording', options || {}); }
5539
5637
  };
5540
5638
  })();
5541
5639
  // Structured workouts on the paired Apple Watch (WorkoutKit,
@@ -6360,12 +6458,12 @@ struct CraftWebView: UIViewRepresentable {
6360
6458
  }
6361
6459
 
6362
6460
  private var locationRecordingStateURL: URL {
6363
- FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
6461
+ (recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
6364
6462
  .appendingPathComponent("craft-location-recording-state.json")
6365
6463
  }
6366
6464
 
6367
6465
  private var locationRecordingTrackURL: URL {
6368
- FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
6466
+ (recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
6369
6467
  .appendingPathComponent("craft-location-recording.jsonl")
6370
6468
  }
6371
6469
 
@@ -6382,54 +6480,142 @@ struct CraftWebView: UIViewRepresentable {
6382
6480
  locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
6383
6481
  }
6384
6482
 
6385
- private func persistLocationRecordingState() {
6386
- let state: [String: Any] = [
6387
- "active": isRecordingLocation,
6388
- "paused": isLocationRecordingPaused,
6389
- "id": locationRecordingId ?? NSNull(),
6390
- "startedAt": locationRecordingStartedAt ?? NSNull(),
6391
- ]
6392
- do {
6393
- let directory = locationRecordingStateURL.deletingLastPathComponent()
6394
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
6395
- let data = try JSONSerialization.data(withJSONObject: state)
6396
- try data.write(to: locationRecordingStateURL, options: .atomic)
6397
- protectLocationFile(locationRecordingStateURL)
6398
- } catch {
6399
- print("Unable to persist location recording state: \(error)")
6400
- }
6483
+ private func persistLocationRecordingState() throws {
6484
+ let state: [String: Any] = ["active": isRecordingLocation, "paused": isLocationRecordingPaused, "id": locationRecordingId ?? NSNull(), "startedAt": locationRecordingStartedAt ?? NSNull()]
6485
+ try CraftRecordingArchive.writeAtomically(JSONSerialization.data(withJSONObject: state), locationRecordingStateURL)
6401
6486
  }
6402
6487
 
6403
- private func restoreLocationRecordingState() {
6404
- guard let data = try? Data(contentsOf: locationRecordingStateURL),
6405
- let state = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
6406
- state["active"] as? Bool == true else { return }
6407
- isRecordingLocation = true
6488
+ private func restoreLocationRecordingState() throws {
6489
+ guard FileManager.default.fileExists(atPath: locationRecordingStateURL.path) else {
6490
+ if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path), !(try Data(contentsOf: locationRecordingTrackURL)).isEmpty {
6491
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Recording metadata is missing")
6492
+ }
6493
+ return
6494
+ }
6495
+ let data = try Data(contentsOf: locationRecordingStateURL)
6496
+ guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
6497
+ let active = state["active"] as? Bool else {
6498
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid recording metadata")
6499
+ }
6500
+ isRecordingLocation = active
6408
6501
  isLocationRecordingPaused = state["paused"] as? Bool ?? false
6409
6502
  locationRecordingId = state["id"] as? String
6410
6503
  locationRecordingStartedAt = state["startedAt"] as? TimeInterval
6411
- if !isLocationRecordingPaused {
6504
+ if let id = locationRecordingId, try CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id) {
6505
+ try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
6506
+ if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
6507
+ locationRecordingId = nil
6508
+ locationRecordingStartedAt = nil
6509
+ isRecordingLocation = false
6510
+ isLocationRecordingPaused = false
6511
+ try persistLocationRecordingState()
6512
+ }
6513
+ if isRecordingLocation && !isLocationRecordingPaused {
6412
6514
  locationManager?.delegate = self
6413
6515
  configureBackgroundLocationIfNeeded()
6414
6516
  locationManager?.startUpdatingLocation()
6415
6517
  }
6416
6518
  }
6417
6519
 
6418
- private func startLocationRecording(callbackId: String?) {
6520
+ /// Same serial main-queue dispatch as the operation it protects.
6521
+ private func requireLocationRecordingOwner(_ body: [String: Any], callbackId: String?) -> Bool {
6522
+ guard body.keys.contains("expectedId") else { return true }
6523
+ guard let expected = body["expectedId"] as? String,
6524
+ !expected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
6525
+ rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
6526
+ return false
6527
+ }
6528
+ guard locationRecordingId == expected else {
6529
+ rejectCallback(callbackId, error: "The active location recording has changed", code: "RECORDING_CONFLICT")
6530
+ return false
6531
+ }
6532
+ return true
6533
+ }
6534
+
6535
+ private var recordingBaseDirectory: URL {
6536
+ recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
6537
+ }
6538
+
6539
+ private func archivedLocationAction(_ action: String, body: [String: Any], callbackId: String?) -> Bool {
6540
+ guard body.keys.contains("expectedId") else {
6541
+ if action == "acknowledgeLocationRecording" {
6542
+ rejectCallback(callbackId, error: "Acknowledgement needs expectedId", code: "INVALID_ARGUMENT")
6543
+ return true
6544
+ }
6545
+ return false
6546
+ }
6547
+ guard let id = body["expectedId"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
6548
+ rejectCallback(callbackId, error: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
6549
+ return true
6550
+ }
6551
+ do {
6552
+ if action == "acknowledgeLocationRecording" {
6553
+ if locationRecordingId == id && isRecordingLocation {
6554
+ rejectCallback(callbackId, error: "The recording is still active", code: "RECORDING_ALREADY_ACTIVE")
6555
+ return true
6556
+ }
6557
+ var known = try locationRecordingId == id || CraftRecordingArchive.isAcknowledged(recordingBaseDirectory, "location", id)
6558
+ if !known { known = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) != nil }
6559
+ guard known else {
6560
+ rejectCallback(callbackId, error: "Unknown recording ID", code: "RECORDING_CONFLICT")
6561
+ return true
6562
+ }
6563
+ try CraftRecordingArchive.acknowledge(recordingBaseDirectory, "location", id)
6564
+ if locationRecordingId == id {
6565
+ if FileManager.default.fileExists(atPath: locationRecordingTrackURL.path) { try FileManager.default.removeItem(at: locationRecordingTrackURL) }
6566
+ locationRecordingId = nil
6567
+ locationRecordingStartedAt = nil
6568
+ try persistLocationRecordingState()
6569
+ }
6570
+ resolveCallback(callbackId, result: true)
6571
+ return true
6572
+ }
6573
+ if var saved = try CraftRecordingArchive.read(recordingBaseDirectory, "location", id) {
6574
+ if action == "readLocationRecording" { resolveCallback(callbackId, result: saved["locations"] ?? []) }
6575
+ else {
6576
+ if action == "getLocationRecordingState" { saved.removeValue(forKey: "locations") }
6577
+ resolveCallback(callbackId, result: saved)
6578
+ }
6579
+ return true
6580
+ }
6581
+ return !requireLocationRecordingOwner(body, callbackId: callbackId)
6582
+ } catch let error as CraftNativeActionError {
6583
+ rejectCallback(callbackId, error: error.message, code: error.code)
6584
+ } catch { rejectCallback(callbackId, error: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR") }
6585
+ return true
6586
+ }
6587
+
6588
+ private func startLocationRecording(body: [String: Any], callbackId: String?) {
6589
+ guard !isRecordingLocation else {
6590
+ rejectCallback(callbackId, error: "A location recording is already active", code: "RECORDING_ALREADY_ACTIVE")
6591
+ return
6592
+ }
6419
6593
  let directory = locationRecordingTrackURL.deletingLastPathComponent()
6594
+ let id: String
6420
6595
  do {
6596
+ id = try CraftRecordingArchive.startId(body, directory: recordingBaseDirectory, kind: "location", currentId: locationRecordingId)
6597
+ try CraftRecordingArchive.save(recordingBaseDirectory, "location", locationRecordingSummary(), samples: try loadRecordedLocationsStrict())
6421
6598
  try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
6422
6599
  try Data().write(to: locationRecordingTrackURL, options: .atomic)
6423
6600
  protectLocationFile(locationRecordingTrackURL)
6601
+ } catch let error as CraftNativeActionError {
6602
+ rejectCallback(callbackId, error: error.message, code: error.code)
6603
+ return
6424
6604
  } catch {
6425
6605
  rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
6426
6606
  return
6427
6607
  }
6428
- locationRecordingId = UUID().uuidString
6608
+ let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
6609
+ locationRecordingId = id
6429
6610
  locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
6430
6611
  isRecordingLocation = true
6431
6612
  isLocationRecordingPaused = false
6432
- persistLocationRecordingState()
6613
+ do { try persistLocationRecordingState() }
6614
+ catch {
6615
+ (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused) = previous
6616
+ rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
6617
+ return
6618
+ }
6433
6619
  locationManager?.delegate = self
6434
6620
  requestLocationAuthorization()
6435
6621
  configureBackgroundLocationIfNeeded()
@@ -6442,8 +6628,14 @@ struct CraftWebView: UIViewRepresentable {
6442
6628
  rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
6443
6629
  return
6444
6630
  }
6631
+ let previous = isLocationRecordingPaused
6445
6632
  isLocationRecordingPaused = true
6446
- persistLocationRecordingState()
6633
+ do { try persistLocationRecordingState() }
6634
+ catch {
6635
+ isLocationRecordingPaused = previous
6636
+ rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
6637
+ return
6638
+ }
6447
6639
  if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
6448
6640
  resolveCallback(callbackId, result: locationRecordingSummary())
6449
6641
  }
@@ -6453,8 +6645,14 @@ struct CraftWebView: UIViewRepresentable {
6453
6645
  rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
6454
6646
  return
6455
6647
  }
6648
+ let previous = isLocationRecordingPaused
6456
6649
  isLocationRecordingPaused = false
6457
- persistLocationRecordingState()
6650
+ do { try persistLocationRecordingState() }
6651
+ catch {
6652
+ isLocationRecordingPaused = previous
6653
+ rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR")
6654
+ return
6655
+ }
6458
6656
  // Set here too: a recording restored at launch while paused never
6459
6657
  // got a delegate, so resuming it started updates nobody received
6460
6658
  // and the route stopped at the pause.
@@ -6466,17 +6664,21 @@ struct CraftWebView: UIViewRepresentable {
6466
6664
  }
6467
6665
 
6468
6666
  private func stopLocationRecording(callbackId: String?) {
6469
- isRecordingLocation = false
6470
- isLocationRecordingPaused = false
6471
- persistLocationRecordingState()
6472
- if !isWatchingLocation {
6473
- locationManager?.stopUpdatingLocation()
6474
- locationManager?.allowsBackgroundLocationUpdates = false
6475
- }
6476
- let locations = loadRecordedLocations()
6477
- var summary = locationRecordingSummary()
6478
- summary["locations"] = locations
6479
- resolveCallback(callbackId, result: summary)
6667
+ do {
6668
+ let locations = try loadRecordedLocationsStrict()
6669
+ let previous = (isRecordingLocation, isLocationRecordingPaused)
6670
+ isRecordingLocation = false
6671
+ isLocationRecordingPaused = false
6672
+ do { try persistLocationRecordingState() }
6673
+ catch { (isRecordingLocation, isLocationRecordingPaused) = previous; throw error }
6674
+ if !isWatchingLocation {
6675
+ locationManager?.stopUpdatingLocation()
6676
+ locationManager?.allowsBackgroundLocationUpdates = false
6677
+ }
6678
+ var summary = locationRecordingSummary()
6679
+ summary["locations"] = locations
6680
+ resolveCallback(callbackId, result: summary)
6681
+ } catch { rejectCallback(callbackId, error: "Unable to persist the location recording", code: "RECORDING_STORAGE_ERROR") }
6480
6682
  }
6481
6683
 
6482
6684
  private func getLocationRecordingState(callbackId: String?) {
@@ -6486,7 +6688,8 @@ struct CraftWebView: UIViewRepresentable {
6486
6688
  }
6487
6689
 
6488
6690
  private func readLocationRecording(callbackId: String?) {
6489
- resolveCallback(callbackId, result: loadRecordedLocations())
6691
+ do { resolveCallback(callbackId, result: try loadRecordedLocationsStrict()) }
6692
+ catch { rejectCallback(callbackId, error: "Unable to read the location recording", code: "RECORDING_STORAGE_ERROR") }
6490
6693
  }
6491
6694
 
6492
6695
  private func locationRecordingSummary() -> [String: Any] {
@@ -6521,8 +6724,11 @@ struct CraftWebView: UIViewRepresentable {
6521
6724
  try? protectedURL.setResourceValues(values)
6522
6725
  }
6523
6726
 
6524
- private func loadRecordedLocations() -> [[String: Any]] {
6525
- guard let text = try? String(contentsOf: locationRecordingTrackURL, encoding: .utf8) else { return [] }
6727
+ private func loadRecordedLocations() -> [[String: Any]] { (try? loadRecordedLocationsStrict()) ?? [] }
6728
+
6729
+ private func loadRecordedLocationsStrict() throws -> [[String: Any]] {
6730
+ if locationRecordingId == nil { return [] }
6731
+ let text = try String(contentsOf: locationRecordingTrackURL, encoding: .utf8)
6526
6732
  return text.split(separator: "\n").compactMap { line in
6527
6733
  guard let data = line.data(using: .utf8) else { return nil }
6528
6734
  return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
@@ -6531,13 +6737,14 @@ struct CraftWebView: UIViewRepresentable {
6531
6737
 
6532
6738
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
6533
6739
  guard let location = locations.last else { return }
6534
- let data = locationData(location)
6740
+ var data = locationData(location)
6535
6741
 
6536
6742
  if let callbackId = singleLocationCallbackId {
6537
6743
  finishSingleLocationRequest()
6538
6744
  resolveCallback(callbackId, result: data)
6539
6745
  }
6540
6746
 
6747
+ if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
6541
6748
  appendRecordedLocation(data)
6542
6749
  if isWatchingLocation || isRecordingLocation {
6543
6750
  sendToWeb("craftLocationUpdate", data: data)
@@ -8832,6 +9039,60 @@ final class CraftZigRuntime: NSObject {
8832
9039
  UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
8833
9040
  ) -> Bool
8834
9041
  private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool
9042
+ private typealias RecordingOwnershipVersionFn = @convention(c) () -> Int32
9043
+ private typealias RecordingReplyFn = @convention(c) (UnsafeMutableRawPointer?, Bool, UnsafePointer<CChar>, UInt) -> Void
9044
+ private typealias PerformRecordingFn = @convention(c) (UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, UnsafeMutableRawPointer?, RecordingReplyFn) -> Bool
9045
+ private final class RecordingReply {
9046
+ var answered = false
9047
+ var result: Result<Any, CraftNativeActionError>?
9048
+
9049
+ }
9050
+ private static let performRecordingFn: PerformRecordingFn? = {
9051
+ guard let sym = dlsym(image, "craft_ios_perform_recording_action") else { return nil }
9052
+ return unsafeBitCast(sym, to: PerformRecordingFn.self)
9053
+ }()
9054
+
9055
+ /// Synchronous native recorder requests use the exact page owner and reply
9056
+ /// envelope, without loading a page or creating a second location manager.
9057
+ static func performRecording(action: String, body: [String: Any], completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
9058
+ guard supportsRecordingOwnership, let performRecordingFn else {
9059
+ if !isLinked { return false }
9060
+ completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked runtime lacks the shared recorder API")))
9061
+ return true
9062
+ }
9063
+ guard let payload = try? JSONSerialization.data(withJSONObject: body),
9064
+ let json = String(data: payload, encoding: .utf8) else {
9065
+ completion(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "Recording options must be JSON")))
9066
+ return true
9067
+ }
9068
+ let reply = RecordingReply()
9069
+ let served = withExtendedLifetime(reply) {
9070
+ action.withCString { a in json.withCString { p in
9071
+ performRecordingFn(a, UInt(strlen(a)), p, UInt(strlen(p)), Unmanaged.passUnretained(reply).toOpaque()) { context, failed, bytes, count in
9072
+ guard let context else { return }
9073
+ let reply = Unmanaged<RecordingReply>.fromOpaque(context).takeUnretainedValue()
9074
+ guard !reply.answered else { return }
9075
+ reply.answered = true
9076
+ let data = Data(bytes: bytes, count: Int(count))
9077
+ guard let result = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) else {
9078
+ reply.result = .failure(CraftNativeActionError(code: "INVALID_RESPONSE", message: "Invalid recorder response"))
9079
+ return
9080
+ }
9081
+ if failed {
9082
+ let error = result as? [String: Any] ?? [:]
9083
+ reply.result = .failure(CraftNativeActionError(code: error["code"] as? String ?? "CRAFT_ERROR", message: error["message"] as? String ?? "Recording failed"))
9084
+ } else { reply.result = .success(result) }
9085
+ }
9086
+ } }
9087
+ }
9088
+ // The C scope has ended before application code can reenter a bridge.
9089
+ if served, let result = reply.result { completion(result) }
9090
+ else {
9091
+ completion(.failure(CraftNativeActionError(code: "RECORDING_OWNERSHIP_UNSUPPORTED", message: "The linked recorder did not serve its synchronous request")))
9092
+ }
9093
+ return true
9094
+ }
9095
+
8835
9096
 
8836
9097
  private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
8837
9098
 
@@ -8859,6 +9120,19 @@ final class CraftZigRuntime: NSObject {
8859
9120
  /// because "is Zig linked" is the first question to ask when an action
8860
9121
  /// behaves like the Swift one after a migration was supposed to move it.
8861
9122
  @objc static var isLinked: Bool { handleActionFn != nil }
9123
+ static var supportsRecordingOwnership: Bool {
9124
+ if !isLinked { return true }
9125
+ guard performRecordingFn != nil,
9126
+ let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
9127
+ return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 1
9128
+ }
9129
+
9130
+ static var supportsRecordingIntentIdentity: Bool {
9131
+ if !isLinked { return true }
9132
+ guard performRecordingFn != nil,
9133
+ let symbol = dlsym(image, "craft_ios_recording_ownership_version") else { return false }
9134
+ return unsafeBitCast(symbol, to: RecordingOwnershipVersionFn.self)() >= 2
9135
+ }
8862
9136
 
8863
9137
  /// Give Zig the webview its replies are evaluated against.
8864
9138
  ///
@@ -8979,6 +9253,9 @@ enum CraftWebServices {
8979
9253
  completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
8980
9254
  return
8981
9255
  }
9256
+ if action.hasSuffix("LocationRecording") || action == "getLocationRecordingState" {
9257
+ if CraftZigRuntime.performRecording(action: action, body: body, completion: completion) { return }
9258
+ }
8982
9259
  let id = "native:\(UUID().uuidString)"
8983
9260
  waiting[id] = completion
8984
9261
  coordinator.dispatch(action: action, body: body, callbackId: id)
@@ -9021,6 +9298,17 @@ enum CraftWebServices {
9021
9298
  /// how this codebase accumulated five envelopes.
9022
9299
  @objc(CraftSwiftShim)
9023
9300
  final class CraftSwiftShim: NSObject {
9301
+ /// Called on main by Zig after serializing its native event once. The
9302
+ /// existing page delivery remains in Zig; these are the native listeners.
9303
+ @objc(forwardNativeEvent:payload:)
9304
+ static func forwardNativeEvent(_ event: String, payload: String) {
9305
+ guard CraftWebServices.forwardedEvents.contains(event),
9306
+ let bytes = payload.data(using: .utf8),
9307
+ let detail = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { return }
9308
+ CraftWebServices.forward(event, data: detail)
9309
+ CraftEventManager.shared.sendToAppScript(event, data: detail)
9310
+ }
9311
+
9024
9312
  /// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
9025
9313
  /// app with no web view by `CraftHybrid.configure`, which holds it.
9026
9314
  static weak var coordinator: CraftWebView.Coordinator?