@craft-native/ios 0.0.220 → 0.0.222

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.220",
3
+ "version": "0.0.222",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1105,23 +1105,58 @@ struct CraftSplashView: View {
1105
1105
  }
1106
1106
 
1107
1107
  // MARK: - Deep Link Manager
1108
+ /// Launch intent settles when UIKit delivers its URL or completes activation,
1109
+ /// independently of when a page or native screen registers its listeners.
1110
+ final class CraftInitialLinkState {
1111
+ private(set) var url: URL?
1112
+ private(set) var settled = false
1113
+ private var readers: [(URL?) -> Void] = []
1114
+
1115
+ func receive(_ url: URL) {
1116
+ guard !settled else { return }
1117
+ self.url = url
1118
+ settle()
1119
+ }
1120
+
1121
+ func settle() {
1122
+ guard !settled else { return }
1123
+ settled = true
1124
+ let waiting = readers
1125
+ readers.removeAll()
1126
+ for reader in waiting { reader(url) }
1127
+ }
1128
+
1129
+ func resolve(_ completion: @escaping (URL?) -> Void) {
1130
+ if settled { completion(url) }
1131
+ else { readers.append(completion) }
1132
+ }
1133
+ }
1134
+
1108
1135
  class DeepLinkManager {
1109
1136
  static let shared = DeepLinkManager()
1110
1137
 
1111
- private var initialURL: URL?
1138
+ private let launch = CraftInitialLinkState()
1139
+ private var initialURL: URL? { launch.url }
1140
+ private var activationObservers: [NSObjectProtocol] = []
1112
1141
  // Every link that arrived while no page could receive it, in order. This
1113
1142
  // was a single slot, so a second link before the bridge was ready replaced
1114
1143
  // the first; on a cold start, that could be the link that launched the app.
1115
1144
  private var pendingURLs: [URL] = []
1116
1145
  private weak var webView: WKWebView?
1117
1146
  private var isReady = false
1118
- // Whether a page has ever become ready. A link that arrives before that is
1119
- // the one the app was opened with; a link that arrives later is not, and
1120
- // is no answer to getInitialURL.
1147
+ // Replay bookkeeping only. A native screen can become ready before UIKit
1148
+ // delivers the launch URL; listener readiness cannot classify that URL.
1121
1149
  private var hasBeenReady = false
1122
1150
  private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
1123
1151
 
1124
- private init() {}
1152
+ private init() {
1153
+ for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
1154
+ activationObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
1155
+ self?.launch.settle()
1156
+ })
1157
+ }
1158
+ if UIApplication.shared.applicationState == .active { launch.settle() }
1159
+ }
1125
1160
 
1126
1161
  func setWebView(_ webView: WKWebView) {
1127
1162
  self.webView = webView
@@ -1146,9 +1181,7 @@ class DeepLinkManager {
1146
1181
  }
1147
1182
 
1148
1183
  func handleURL(_ url: URL) {
1149
- if initialURL == nil && !hasBeenReady {
1150
- initialURL = url
1151
- }
1184
+ launch.receive(url)
1152
1185
 
1153
1186
  // A hybrid app opens a native path's screen itself, and tells the
1154
1187
  // native screens that listen; every other link is the page's. An
@@ -1179,6 +1212,17 @@ class DeepLinkManager {
1179
1212
  return initialURL
1180
1213
  }
1181
1214
 
1215
+ /// A foreground scene can draw before SwiftUI delivers its launch activity.
1216
+ /// A headless background launch has no scene or foreground URL to await.
1217
+ func resolveInitialURL(_ completion: @escaping (URL?) -> Void) {
1218
+ let application = UIApplication.shared
1219
+ if application.applicationState == .active ||
1220
+ (application.applicationState == .background && application.connectedScenes.isEmpty) {
1221
+ launch.settle()
1222
+ }
1223
+ launch.resolve(completion)
1224
+ }
1225
+
1182
1226
  @discardableResult
1183
1227
  func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
1184
1228
  let token = UUID()
@@ -135,6 +135,13 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
135
135
  /// Fourteen hours of one reading a second: more is not one workout.
136
136
  static let maxRecordedSamples = 50_000
137
137
 
138
+ private let clock: () -> Date
139
+
140
+ init(now: @escaping () -> Date = Date.init) {
141
+ self.clock = now
142
+ super.init()
143
+ }
144
+
138
145
  /// The web view's coordinator, which sends an event to the page and every
139
146
  /// native screen. False (or unset, in an app without a web view) and the
140
147
  /// event goes to the native screens alone.
@@ -155,6 +162,12 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
155
162
  private var connectDeadline: DispatchWorkItem?
156
163
  private var latest: CraftHeartRateMeasurement?
157
164
  private var latestAt: Date?
165
+ // Packet freshness is separate from a valid BPM: a monitor reporting no
166
+ // skin contact is still delivering notifications and needs no reconnect.
167
+ private var packetAt: Date?
168
+ private var subscriptionAt: Date?
169
+ private var recoveryTimer: Timer?
170
+ private var hasSubscribed = false
158
171
 
159
172
  private var recordingId: String?
160
173
  private var recordingStartedAt: Double?
@@ -189,7 +202,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
189
202
  DispatchQueue.main.async {
190
203
  self.restoreRecording()
191
204
  switch method {
192
- case "getState": completion(.success(self.state()))
205
+ case "getState":
206
+ self.recoverQuietBroadcast()
207
+ completion(.success(self.state()))
193
208
  case "requestAuthorization": self.requestAuthorization(completion: completion)
194
209
  case "scan": self.scan(seconds: (options["timeout"] as? NSNumber)?.doubleValue, completion: completion)
195
210
  case "stopScan":
@@ -255,7 +270,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
255
270
  }
256
271
 
257
272
  func state() -> [String: Any] {
258
- let fresh = latestAt.map { Date().timeIntervalSince($0) <= Self.staleAfter } ?? false
273
+ let fresh = latestAt.map { clock().timeIntervalSince($0) <= Self.staleAfter } ?? false
259
274
  let device: Any = deviceId.map { ["id": $0.uuidString, "name": deviceName ?? "Heart rate monitor"] as [String: Any] } ?? NSNull()
260
275
  return [
261
276
  "available": bluetooth != "unsupported",
@@ -393,8 +408,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
393
408
  }
394
409
  whenReady {
395
410
  if let reason = self.unusable() { return completion(.failure(reason)) }
396
- // Already there, or on the way: nothing to start again.
411
+ // A connected link can lose its notification subscription when a
412
+ // broadcasting watch restarts. Keep healthy links, repair quiet ones.
397
413
  if self.deviceId == uuid && self.wantsConnection {
414
+ self.recoverQuietBroadcast()
398
415
  if self.connection == "connected" || !wait { return completion(.success(self.state())) }
399
416
  self.connectWaiters.append(completion)
400
417
  self.armConnectDeadline()
@@ -416,6 +433,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
416
433
  self.deviceName = name
417
434
  self.wantsConnection = true
418
435
  self.connection = "connecting"
436
+ self.subscriptionAt = self.clock()
437
+ self.startRecoveryWatch()
419
438
  self.remember(id: uuid, name: name)
420
439
  if wait {
421
440
  self.connectWaiters.append(completion)
@@ -454,6 +473,11 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
454
473
  /// Lets go of the current strap without telling anyone.
455
474
  private func release() {
456
475
  wantsConnection = false
476
+ hasSubscribed = false
477
+ recoveryTimer?.invalidate()
478
+ recoveryTimer = nil
479
+ packetAt = nil
480
+ subscriptionAt = nil
457
481
  simulatedTimer?.invalidate()
458
482
  simulatedTimer = nil
459
483
  if let peripheral, let central, peripheral.state != .disconnected { central.cancelPeripheralConnection(peripheral) }
@@ -474,7 +498,9 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
474
498
  }
475
499
 
476
500
  func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
477
- guard peripheral.identifier == deviceId else { return }
501
+ guard peripheral.identifier == deviceId, wantsConnection else { return }
502
+ packetAt = nil
503
+ subscriptionAt = clock()
478
504
  peripheral.delegate = self
479
505
  peripheral.discoverServices([Self.serviceUUID])
480
506
  }
@@ -491,6 +517,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
491
517
  guard peripheral.identifier == deviceId else { return }
492
518
  latest = nil
493
519
  latestAt = nil
520
+ packetAt = nil
521
+ subscriptionAt = clock()
494
522
  if wantsConnection {
495
523
  connection = "reconnecting"
496
524
  central.connect(peripheral)
@@ -501,7 +529,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
501
529
  }
502
530
 
503
531
  func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
532
+ guard peripheral.identifier == deviceId, wantsConnection else { return }
533
+ if error != nil { return restartBroadcast() }
504
534
  guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
535
+ if hasSubscribed { return restartBroadcast() }
505
536
  notAHeartRateMonitor(peripheral)
506
537
  return
507
538
  }
@@ -509,7 +540,10 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
509
540
  }
510
541
 
511
542
  func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
543
+ guard peripheral.identifier == deviceId, wantsConnection, service.uuid == Self.serviceUUID else { return }
544
+ if error != nil { return restartBroadcast() }
512
545
  guard let measurement = service.characteristics?.first(where: { $0.uuid == Self.measurementUUID }) else {
546
+ if hasSubscribed { return restartBroadcast() }
513
547
  notAHeartRateMonitor(peripheral)
514
548
  return
515
549
  }
@@ -517,36 +551,104 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
517
551
  }
518
552
 
519
553
  func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
520
- guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId else { return }
521
- if characteristic.isNotifying {
554
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
555
+ if characteristic.isNotifying && error == nil {
556
+ hasSubscribed = true
557
+ subscriptionAt = clock()
522
558
  connection = "connected"
523
559
  emitState()
524
560
  settleConnect(.success(state()))
525
- } else if let error {
526
- settleConnect(.failure(CraftHeartRateError(message: error.localizedDescription, code: "CONNECT_FAILED")))
561
+ } else {
562
+ restartBroadcast()
527
563
  }
528
564
  }
529
565
 
566
+ // Garmin may invalidate its service when HR broadcast is toggled without
567
+ // dropping the physical link. Discover and subscribe to the replacement.
568
+ func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
569
+ guard peripheral.identifier == deviceId, wantsConnection,
570
+ invalidatedServices.contains(where: { $0.uuid == Self.serviceUUID }) else { return }
571
+ connection = "reconnecting"
572
+ latest = nil
573
+ latestAt = nil
574
+ packetAt = nil
575
+ subscriptionAt = clock()
576
+ peripheral.discoverServices([Self.serviceUUID])
577
+ emitState()
578
+ }
579
+
530
580
  func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
531
- guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, let value = characteristic.value else { return }
581
+ guard characteristic.uuid == Self.measurementUUID, peripheral.identifier == deviceId, wantsConnection else { return }
582
+ if error != nil { return restartBroadcast() }
583
+ guard let value = characteristic.value else { return }
532
584
  received([UInt8](value))
533
585
  }
534
586
 
535
587
  private func notAHeartRateMonitor(_ peripheral: CBPeripheral) {
536
588
  wantsConnection = false
589
+ hasSubscribed = false
590
+ recoveryTimer?.invalidate()
591
+ recoveryTimer = nil
537
592
  central?.cancelPeripheralConnection(peripheral)
538
593
  connection = "disconnected"
539
594
  settleConnect(.failure(CraftHeartRateError(message: "That device does not send a heart rate", code: "NOT_HEART_RATE_MONITOR")))
540
595
  emitState()
541
596
  }
542
597
 
598
+ /// Run independently of any player or big-timer screen. A pending radio
599
+ /// connection stays pending until the watch returns; only a connected link
600
+ /// with a silent subscription is restarted.
601
+ private func startRecoveryWatch() {
602
+ recoveryTimer?.invalidate()
603
+ recoveryTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
604
+ self?.recoverQuietBroadcast()
605
+ }
606
+ }
607
+
608
+ private func recoverQuietBroadcast() {
609
+ guard wantsConnection, let since = packetAt ?? subscriptionAt else { return }
610
+ let quiet = clock().timeIntervalSince(since)
611
+ if connection == "connected" && quiet > 20 {
612
+ restartBroadcast()
613
+ } else if connection != "connected", peripheral?.state == .connected, quiet > 30 {
614
+ restartBroadcast()
615
+ }
616
+ }
617
+
618
+ /// Keep the recording and device choice while replacing the subscription.
619
+ /// CoreBluetooth's disconnect callback starts the next connection once the
620
+ /// previous one has actually closed.
621
+ private func restartBroadcast() {
622
+ guard wantsConnection else { return }
623
+ connection = "reconnecting"
624
+ latest = nil
625
+ latestAt = nil
626
+ packetAt = nil
627
+ subscriptionAt = clock()
628
+ emitState()
629
+ if simulated {
630
+ simulatedTimer?.invalidate()
631
+ simulatedTimer = nil
632
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.simulatedConnected() }
633
+ } else if let peripheral, let central, central.state == .poweredOn {
634
+ if peripheral.state == .disconnected { central.connect(peripheral) }
635
+ else if peripheral.state != .disconnecting { central.cancelPeripheralConnection(peripheral) }
636
+ }
637
+ }
638
+
543
639
  // MARK: Readings
544
640
 
545
641
  private func received(_ bytes: [UInt8]) {
546
642
  // A strap reads 0 between losing the skin and giving up; that is no
547
643
  // heart rate, and neither is anything a heart could not do.
548
- guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.bpm >= 20, reading.bpm <= 250 else { return }
549
- let now = Date()
644
+ packetAt = clock()
645
+ guard let reading = CraftHeartRateMeasurement.parse(bytes), reading.sensorContact != false, reading.bpm >= 20, reading.bpm <= 250 else {
646
+ latest = nil
647
+ latestAt = nil
648
+ emitState()
649
+ return
650
+ }
651
+ let now = clock()
550
652
  latest = reading
551
653
  latestAt = now
552
654
  let timestamp = (now.timeIntervalSince1970 * 1000).rounded()
@@ -567,6 +669,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
567
669
  private func simulatedConnected() {
568
670
  guard simulated, wantsConnection, deviceId == Self.simulatedId else { return }
569
671
  connection = "connected"
672
+ subscriptionAt = clock()
570
673
  emitState()
571
674
  settleConnect(.success(state()))
572
675
  simulatedTimer?.invalidate()
@@ -671,7 +774,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
671
774
  return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
672
775
  }
673
776
  recordingId = UUID().uuidString
674
- recordingStartedAt = (Date().timeIntervalSince1970 * 1000).rounded()
777
+ recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
675
778
  recordingPaused = false
676
779
  recordedCount = 0
677
780
  persistRecording()
@@ -258,7 +258,10 @@ enum CraftNativeActions {
258
258
  sync = .success(currentAppState())
259
259
  case ("DeepLinks", "getInitialURL"):
260
260
  if config.enableDeepLinks {
261
- sync = .success(claimInitialDeepLink().map(deepLinkData) ?? NSNull())
261
+ DeepLinkManager.shared.resolveInitialURL { url in
262
+ complete(requestToken, .success(claimInitialDeepLink(url).map(deepLinkData) ?? NSNull()), completion)
263
+ }
264
+ return
262
265
  } else {
263
266
  sync = failure("CAPABILITY_DISABLED", "Deep links are disabled")
264
267
  }
@@ -809,12 +812,12 @@ enum CraftNativeActions {
809
812
  else { task.resume() }
810
813
  }
811
814
 
812
- private static func claimInitialDeepLink() -> URL? {
815
+ private static func claimInitialDeepLink(_ url: URL?) -> URL? {
813
816
  cancellationLock.lock()
814
817
  defer { cancellationLock.unlock() }
815
818
  guard !initialDeepLinkClaimed else { return nil }
816
819
  initialDeepLinkClaimed = true
817
- return DeepLinkManager.shared.getInitialURL()
820
+ return url
818
821
  }
819
822
 
820
823
  private static func complete(