@craft-native/ios 0.0.236 → 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.
@@ -205,6 +205,12 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
205
205
  // push, a refresh, a route recording's location) draws nothing, and
206
206
  // its events still need someone to hear them.
207
207
  CraftHybrid.shared.configure(config)
208
+ if let shortcuts = config.appShortcuts {
209
+ application.shortcutItems = shortcuts.prefix(4).map { shortcut in
210
+ UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
211
+ icon: UIApplicationShortcutIcon(systemImageName: shortcut.symbol), userInfo: ["url": shortcut.url as NSString])
212
+ }
213
+ }
208
214
  if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
209
215
  CraftEventManager.shared.handleShortcut(shortcut)
210
216
  // Returning false tells UIKit the launch-time item was handled and
@@ -415,6 +421,12 @@ class CraftEventManager {
415
421
  }
416
422
  }
417
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
+
418
430
  /// An event no one will hear: logged, and background work it holds is
419
431
  /// answered at once.
420
432
  private func drop(_ event: String, data: [String: Any]) {
@@ -607,6 +619,12 @@ struct CraftApp: App {
607
619
  // images and all, and a tab bar or a splash that waited for it would be late
608
620
  // on every launch.
609
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
+
610
628
  final class CraftChrome: ObservableObject {
611
629
  static let shared = CraftChrome()
612
630
 
@@ -619,6 +637,21 @@ final class CraftChrome: ObservableObject {
619
637
  var titleOverride: String? = nil
620
638
  var iconTint: String? = nil
621
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
+ }
622
655
  var displayTitle: String { titleOverride ?? title }
623
656
  var accessibilityLabel: String? = nil
624
657
  var image: String? = nil
@@ -627,7 +660,11 @@ final class CraftChrome: ObservableObject {
627
660
  @Published var tabs: [Tab] = []
628
661
  @Published var tabAccessory: Tab?
629
662
  @Published var selected: String?
630
- @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
+ }
631
668
  @Published var tabBarVisible = false
632
669
  @Published var splashVisible = true
633
670
  @Published var tint: UIColor?
@@ -669,7 +706,7 @@ final class CraftChrome: ObservableObject {
669
706
  splashVisible = splash
670
707
  tabSwipeEnabled = true
671
708
  guard !configured.isEmpty else { return }
672
- 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) }
673
710
  self.selected = selected ?? configured.first?.id
674
711
  tabBarVisible = true
675
712
  }
@@ -682,27 +719,42 @@ final class CraftChrome: ObservableObject {
682
719
  /// Change one tab's caption or icon without replacing stacks or selection.
683
720
  func updateTab(_ item: [String: Any]) {
684
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]
685
726
  if item.keys.contains("iconText") {
686
- 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)) }
687
728
  }
688
729
  if item.keys.contains("title") {
689
- 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)) }
690
731
  }
691
732
  if item.keys.contains("iconTint") {
692
- 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 }
693
734
  }
694
735
  if item.keys.contains("iconPulse") {
695
- 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
696
746
  }
697
747
  if item.keys.contains("accessibilityLabel") {
698
- tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
748
+ next.accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
699
749
  }
700
750
  if item.keys.contains("image") {
701
- 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 }
702
752
  }
703
753
  if item.keys.contains("badge") {
704
- 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)) }
705
755
  }
756
+ guard next != tabs[index] else { return }
757
+ tabs[index] = next
706
758
  }
707
759
  /// A destination beside the tab pill, outside the tab stacks and swipe order.
708
760
  func updateTabAccessory(_ item: [String: Any]?) {
@@ -746,7 +798,10 @@ final class CraftChrome: ObservableObject {
746
798
  tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
747
799
  guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
748
800
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
749
- 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)
750
805
  }
751
806
  if let selected = body["selected"] as? String {
752
807
  self.selected = selected
@@ -1102,18 +1157,7 @@ struct CraftTabBarView: View {
1102
1157
  }
1103
1158
  }
1104
1159
  .background {
1105
- GeometryReader { geometry in
1106
- if let position = chrome.tabSwipePosition
1107
- ?? chrome.tabs.firstIndex(where: { $0.id == chrome.selected }).map({ CGFloat($0) }) {
1108
- let width = geometry.size.width / CGFloat(max(1, chrome.tabs.count))
1109
- Capsule()
1110
- .fill(Color.primary.opacity(0.09))
1111
- .frame(width: width, height: geometry.size.height)
1112
- .offset(x: position * width)
1113
- }
1114
- }
1115
- .allowsHitTesting(false)
1116
- .accessibilityHidden(true)
1160
+ CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
1117
1161
  }
1118
1162
  }
1119
1163
 
@@ -1123,13 +1167,15 @@ struct CraftTabBarView: View {
1123
1167
 
1124
1168
  /// Live styling belongs to the icon, leaving selection and the caption clear.
1125
1169
  @ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
1126
- let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
1127
- 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)
1128
1174
  .font(.system(size: iconSize, weight: selected ? .semibold : .regular))
1129
1175
  .foregroundStyle(color)
1130
1176
  .frame(height: iconSize + 6)
1131
1177
  if #available(iOS 17.0, *) {
1132
- icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
1178
+ icon.symbolEffect(.pulse, options: .repeating, isActive: appearance.pulse)
1133
1179
  } else {
1134
1180
  icon
1135
1181
  }
@@ -1142,6 +1188,26 @@ struct CraftTabBarView: View {
1142
1188
  }
1143
1189
  }
1144
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
+
1145
1211
  /// The launch screen, held until the page is ready: the same colour and the
1146
1212
  /// same logo at the same place, so the hand-over from the system's launch
1147
1213
  /// screen cannot be seen.
@@ -1399,7 +1465,17 @@ extension CraftConfig {
1399
1465
  }
1400
1466
 
1401
1467
  // MARK: - Configuration
1468
+ struct CraftConfiguredShortcut: Codable {
1469
+ var id: String
1470
+ var title: String
1471
+ var subtitle: String?
1472
+ var symbol: String
1473
+ var url: String
1474
+ var phrases: [String]
1475
+ }
1476
+
1402
1477
  struct CraftConfig: Codable {
1478
+ var appShortcuts: [CraftConfiguredShortcut]? = nil
1403
1479
  var appName: String = "Craft App"
1404
1480
  var bundleId: String = "com.craft.app"
1405
1481
  /// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
@@ -1501,6 +1577,9 @@ struct CraftConfig: Codable {
1501
1577
  var id: String
1502
1578
  var title: String
1503
1579
  var symbol: String? = nil
1580
+ var selectedSymbol: String? = nil
1581
+ var selectedIconTint: String? = nil
1582
+ var selectedIconPulse: Bool? = nil
1504
1583
  }
1505
1584
 
1506
1585
  struct BackgroundRefresh: Codable {
@@ -1919,6 +1998,8 @@ struct CraftWebView: UIViewRepresentable {
1919
1998
  private var isLocationRecordingPaused = false
1920
1999
  private var locationRecordingId: String?
1921
2000
  private var locationRecordingStartedAt: TimeInterval?
2001
+ private let recordingDirectory: URL?
2002
+ private var locationRecordingRestoreError: Error?
1922
2003
 
1923
2004
  // Network monitoring
1924
2005
  private var networkMonitor: NWPathMonitor?
@@ -1984,7 +2065,8 @@ struct CraftWebView: UIViewRepresentable {
1984
2065
  // SQLite database
1985
2066
  private var db: OpaquePointer?
1986
2067
 
1987
- init(config: CraftConfig) {
2068
+ init(config: CraftConfig, recordingDirectory: URL? = nil) {
2069
+ self.recordingDirectory = recordingDirectory
1988
2070
  self.config = config
1989
2071
  super.init()
1990
2072
  if config.enableSpeechRecognition {
@@ -1999,7 +2081,8 @@ struct CraftWebView: UIViewRepresentable {
1999
2081
  // relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
2000
2082
  // Running both would leave two managers appending to one track.
2001
2083
  if !CraftZigRuntime.adoptLocationRecording() {
2002
- restoreLocationRecordingState()
2084
+ do { try restoreLocationRecordingState() }
2085
+ catch { locationRecordingRestoreError = error }
2003
2086
  }
2004
2087
  }
2005
2088
  if config.enableContacts {
@@ -2149,6 +2232,13 @@ struct CraftWebView: UIViewRepresentable {
2149
2232
  /// `craft_ios_deliver_result` instead of evaluating JavaScript here —
2150
2233
  /// one reply path, owned by whichever side received the page's message.
2151
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 }
2152
2242
  if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
2153
2243
  switch answer {
2154
2244
  case .success(let value): resolveCallback(callbackId, result: value)
@@ -2345,20 +2435,22 @@ struct CraftWebView: UIViewRepresentable {
2345
2435
  resolveCallback(callbackId, result: true)
2346
2436
  case "startLocationRecording":
2347
2437
  if config.enableGeolocation {
2348
- startLocationRecording(callbackId: callbackId)
2438
+ startLocationRecording(body: body, callbackId: callbackId)
2349
2439
  } else {
2350
2440
  rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
2351
2441
  }
2352
2442
  case "pauseLocationRecording":
2353
- pauseLocationRecording(callbackId: callbackId)
2443
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { pauseLocationRecording(callbackId: callbackId) }
2354
2444
  case "resumeLocationRecording":
2355
- resumeLocationRecording(callbackId: callbackId)
2445
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { resumeLocationRecording(callbackId: callbackId) }
2356
2446
  case "stopLocationRecording":
2357
- stopLocationRecording(callbackId: callbackId)
2447
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { stopLocationRecording(callbackId: callbackId) }
2358
2448
  case "getLocationRecordingState":
2359
2449
  getLocationRecordingState(callbackId: callbackId)
2360
2450
  case "readLocationRecording":
2361
- readLocationRecording(callbackId: callbackId)
2451
+ if requireLocationRecordingOwner(body, callbackId: callbackId) { readLocationRecording(callbackId: callbackId) }
2452
+ case "acknowledgeLocationRecording":
2453
+ _ = archivedLocationAction(action, body: body, callbackId: callbackId)
2362
2454
  // App Badge
2363
2455
  case "setBadge":
2364
2456
  if let count = body["count"] as? Int {
@@ -3666,6 +3758,9 @@ struct CraftWebView: UIViewRepresentable {
3666
3758
  pushNotifications: \(config.enablePushNotifications),
3667
3759
  secureStorage: \(config.enableSecureStorage),
3668
3760
  geolocation: \(config.enableGeolocation),
3761
+ crypto: true,
3762
+ locationRecordingOwnership: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingOwnership),
3763
+ locationRecordingIntentIdentity: \(config.enableGeolocation && CraftZigRuntime.supportsRecordingIntentIdentity),
3669
3764
  backgroundLocation: \(config.enableBackgroundLocation),
3670
3765
  healthKit: \(config.enableHealthKit),
3671
3766
  liveActivities: \(config.enableLiveActivities),
@@ -3682,6 +3777,8 @@ struct CraftWebView: UIViewRepresentable {
3682
3777
  brightness: true,
3683
3778
  music: \(config.enableAppleMusic),
3684
3779
  heartRate: \(config.enableHeartRate),
3780
+ heartRateRecordingOwnership: \(config.enableHeartRate),
3781
+ heartRateRecordingIntentIdentity: \(config.enableHeartRate),
3685
3782
  scheduledWorkouts: \(config.enableScheduledWorkouts),
3686
3783
  speech: true,
3687
3784
  crashReporter: \(CraftCrashReporter.shared.enabled),
@@ -5266,6 +5363,20 @@ struct CraftWebView: UIViewRepresentable {
5266
5363
  browserOpen: true
5267
5364
  };
5268
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
+ }
5269
5380
  var self = craft;
5270
5381
  var id = 'cb_' + (++self._callbackId);
5271
5382
  var message = Object.assign({}, payload || {}, {action: action, callbackId: id});
@@ -5403,6 +5514,7 @@ struct CraftWebView: UIViewRepresentable {
5403
5514
  legacyLocationWatchCallbacks.length = 0;
5404
5515
  return stopLocationWatchIfUnused();
5405
5516
  };
5517
+ craft.crypto = { randomUUID: function() { return craft._invoke('cryptoRandomUUID'); } };
5406
5518
  craft.location = {
5407
5519
  getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
5408
5520
  watchPosition: function(callback) {
@@ -5421,12 +5533,13 @@ struct CraftWebView: UIViewRepresentable {
5421
5533
  if (!locationWatchCallbacks.delete(id)) return;
5422
5534
  void stopLocationWatchIfUnused().catch(function() {});
5423
5535
  },
5424
- startRecording: function() { return craft._invoke('startLocationRecording'); },
5425
- pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
5426
- resumeRecording: function() { return craft._invoke('resumeLocationRecording'); },
5427
- stopRecording: function() { return craft._invoke('stopLocationRecording'); },
5428
- getRecordingState: function() { return craft._invoke('getLocationRecordingState'); },
5429
- 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 || {}); }
5430
5543
  };
5431
5544
  craft.health = {
5432
5545
  requestAuthorization: function(types, options) {
@@ -5514,12 +5627,13 @@ struct CraftWebView: UIViewRepresentable {
5514
5627
  connect: function(id) { return heartRate('connect', {id: typeof id === 'string' ? id : id && id.id}); },
5515
5628
  reconnect: function() { return heartRate('reconnect'); },
5516
5629
  disconnect: function(options) { return heartRate('disconnect', {forget: !!(options && options.forget)}); },
5517
- startRecording: function() { return heartRate('startRecording'); },
5518
- pauseRecording: function() { return heartRate('pauseRecording'); },
5519
- resumeRecording: function() { return heartRate('resumeRecording'); },
5520
- stopRecording: function() { return heartRate('stopRecording'); },
5521
- getRecordingState: function() { return heartRate('getRecordingState'); },
5522
- 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 || {}); }
5523
5637
  };
5524
5638
  })();
5525
5639
  // Structured workouts on the paired Apple Watch (WorkoutKit,
@@ -6344,12 +6458,12 @@ struct CraftWebView: UIViewRepresentable {
6344
6458
  }
6345
6459
 
6346
6460
  private var locationRecordingStateURL: URL {
6347
- FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
6461
+ (recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
6348
6462
  .appendingPathComponent("craft-location-recording-state.json")
6349
6463
  }
6350
6464
 
6351
6465
  private var locationRecordingTrackURL: URL {
6352
- FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
6466
+ (recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0])
6353
6467
  .appendingPathComponent("craft-location-recording.jsonl")
6354
6468
  }
6355
6469
 
@@ -6366,54 +6480,142 @@ struct CraftWebView: UIViewRepresentable {
6366
6480
  locationManager?.showsBackgroundLocationIndicator = config.enableBackgroundLocation
6367
6481
  }
6368
6482
 
6369
- private func persistLocationRecordingState() {
6370
- let state: [String: Any] = [
6371
- "active": isRecordingLocation,
6372
- "paused": isLocationRecordingPaused,
6373
- "id": locationRecordingId ?? NSNull(),
6374
- "startedAt": locationRecordingStartedAt ?? NSNull(),
6375
- ]
6376
- do {
6377
- let directory = locationRecordingStateURL.deletingLastPathComponent()
6378
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
6379
- let data = try JSONSerialization.data(withJSONObject: state)
6380
- try data.write(to: locationRecordingStateURL, options: .atomic)
6381
- protectLocationFile(locationRecordingStateURL)
6382
- } catch {
6383
- print("Unable to persist location recording state: \(error)")
6384
- }
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)
6385
6486
  }
6386
6487
 
6387
- private func restoreLocationRecordingState() {
6388
- guard let data = try? Data(contentsOf: locationRecordingStateURL),
6389
- let state = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
6390
- state["active"] as? Bool == true else { return }
6391
- 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
6392
6501
  isLocationRecordingPaused = state["paused"] as? Bool ?? false
6393
6502
  locationRecordingId = state["id"] as? String
6394
6503
  locationRecordingStartedAt = state["startedAt"] as? TimeInterval
6395
- 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 {
6396
6514
  locationManager?.delegate = self
6397
6515
  configureBackgroundLocationIfNeeded()
6398
6516
  locationManager?.startUpdatingLocation()
6399
6517
  }
6400
6518
  }
6401
6519
 
6402
- 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
+ }
6403
6593
  let directory = locationRecordingTrackURL.deletingLastPathComponent()
6594
+ let id: String
6404
6595
  do {
6596
+ id = try CraftRecordingArchive.startId(body, directory: recordingBaseDirectory, kind: "location", currentId: locationRecordingId)
6597
+ try CraftRecordingArchive.save(recordingBaseDirectory, "location", locationRecordingSummary(), samples: try loadRecordedLocationsStrict())
6405
6598
  try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
6406
6599
  try Data().write(to: locationRecordingTrackURL, options: .atomic)
6407
6600
  protectLocationFile(locationRecordingTrackURL)
6601
+ } catch let error as CraftNativeActionError {
6602
+ rejectCallback(callbackId, error: error.message, code: error.code)
6603
+ return
6408
6604
  } catch {
6409
6605
  rejectCallback(callbackId, error: "Unable to create the location recording", code: "RECORDING_STORAGE_ERROR")
6410
6606
  return
6411
6607
  }
6412
- locationRecordingId = UUID().uuidString
6608
+ let previous = (locationRecordingId, locationRecordingStartedAt, isRecordingLocation, isLocationRecordingPaused)
6609
+ locationRecordingId = id
6413
6610
  locationRecordingStartedAt = Date().timeIntervalSince1970 * 1000
6414
6611
  isRecordingLocation = true
6415
6612
  isLocationRecordingPaused = false
6416
- 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
+ }
6417
6619
  locationManager?.delegate = self
6418
6620
  requestLocationAuthorization()
6419
6621
  configureBackgroundLocationIfNeeded()
@@ -6426,8 +6628,14 @@ struct CraftWebView: UIViewRepresentable {
6426
6628
  rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
6427
6629
  return
6428
6630
  }
6631
+ let previous = isLocationRecordingPaused
6429
6632
  isLocationRecordingPaused = true
6430
- 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
+ }
6431
6639
  if !isWatchingLocation { locationManager?.stopUpdatingLocation() }
6432
6640
  resolveCallback(callbackId, result: locationRecordingSummary())
6433
6641
  }
@@ -6437,8 +6645,14 @@ struct CraftWebView: UIViewRepresentable {
6437
6645
  rejectCallback(callbackId, error: "No active location recording", code: "NO_ACTIVE_RECORDING")
6438
6646
  return
6439
6647
  }
6648
+ let previous = isLocationRecordingPaused
6440
6649
  isLocationRecordingPaused = false
6441
- 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
+ }
6442
6656
  // Set here too: a recording restored at launch while paused never
6443
6657
  // got a delegate, so resuming it started updates nobody received
6444
6658
  // and the route stopped at the pause.
@@ -6450,17 +6664,21 @@ struct CraftWebView: UIViewRepresentable {
6450
6664
  }
6451
6665
 
6452
6666
  private func stopLocationRecording(callbackId: String?) {
6453
- isRecordingLocation = false
6454
- isLocationRecordingPaused = false
6455
- persistLocationRecordingState()
6456
- if !isWatchingLocation {
6457
- locationManager?.stopUpdatingLocation()
6458
- locationManager?.allowsBackgroundLocationUpdates = false
6459
- }
6460
- let locations = loadRecordedLocations()
6461
- var summary = locationRecordingSummary()
6462
- summary["locations"] = locations
6463
- 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") }
6464
6682
  }
6465
6683
 
6466
6684
  private func getLocationRecordingState(callbackId: String?) {
@@ -6470,7 +6688,8 @@ struct CraftWebView: UIViewRepresentable {
6470
6688
  }
6471
6689
 
6472
6690
  private func readLocationRecording(callbackId: String?) {
6473
- 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") }
6474
6693
  }
6475
6694
 
6476
6695
  private func locationRecordingSummary() -> [String: Any] {
@@ -6505,8 +6724,11 @@ struct CraftWebView: UIViewRepresentable {
6505
6724
  try? protectedURL.setResourceValues(values)
6506
6725
  }
6507
6726
 
6508
- private func loadRecordedLocations() -> [[String: Any]] {
6509
- 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)
6510
6732
  return text.split(separator: "\n").compactMap { line in
6511
6733
  guard let data = line.data(using: .utf8) else { return nil }
6512
6734
  return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
@@ -6515,13 +6737,14 @@ struct CraftWebView: UIViewRepresentable {
6515
6737
 
6516
6738
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
6517
6739
  guard let location = locations.last else { return }
6518
- let data = locationData(location)
6740
+ var data = locationData(location)
6519
6741
 
6520
6742
  if let callbackId = singleLocationCallbackId {
6521
6743
  finishSingleLocationRequest()
6522
6744
  resolveCallback(callbackId, result: data)
6523
6745
  }
6524
6746
 
6747
+ if isRecordingLocation, let id = locationRecordingId { data["recordingId"] = id }
6525
6748
  appendRecordedLocation(data)
6526
6749
  if isWatchingLocation || isRecordingLocation {
6527
6750
  sendToWeb("craftLocationUpdate", data: data)
@@ -8816,6 +9039,60 @@ final class CraftZigRuntime: NSObject {
8816
9039
  UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
8817
9040
  ) -> Bool
8818
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
+
8819
9096
 
8820
9097
  private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
8821
9098
 
@@ -8843,6 +9120,19 @@ final class CraftZigRuntime: NSObject {
8843
9120
  /// because "is Zig linked" is the first question to ask when an action
8844
9121
  /// behaves like the Swift one after a migration was supposed to move it.
8845
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
+ }
8846
9136
 
8847
9137
  /// Give Zig the webview its replies are evaluated against.
8848
9138
  ///
@@ -8963,6 +9253,9 @@ enum CraftWebServices {
8963
9253
  completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "\(action) needs the app's web view, which this app does not have")))
8964
9254
  return
8965
9255
  }
9256
+ if action.hasSuffix("LocationRecording") || action == "getLocationRecordingState" {
9257
+ if CraftZigRuntime.performRecording(action: action, body: body, completion: completion) { return }
9258
+ }
8966
9259
  let id = "native:\(UUID().uuidString)"
8967
9260
  waiting[id] = completion
8968
9261
  coordinator.dispatch(action: action, body: body, callbackId: id)
@@ -9005,6 +9298,17 @@ enum CraftWebServices {
9005
9298
  /// how this codebase accumulated five envelopes.
9006
9299
  @objc(CraftSwiftShim)
9007
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
+
9008
9312
  /// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
9009
9313
  /// app with no web view by `CraftHybrid.configure`, which holds it.
9010
9314
  static weak var coordinator: CraftWebView.Coordinator?