craft-native 0.0.369 → 0.0.374

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.
@@ -1,4 +1,6 @@
1
+ #if CRAFT_PERMISSION_BLUETOOTH
1
2
  import CoreBluetooth
3
+ #endif
2
4
  import Foundation
3
5
 
4
6
  struct CraftBluetoothError: Error {
@@ -7,6 +9,7 @@ struct CraftBluetoothError: Error {
7
9
  }
8
10
 
9
11
  /// One explicitly chosen GATT channel. Scanning is user initiated; no vendor API.
12
+ #if CRAFT_PERMISSION_BLUETOOTH
10
13
  final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
11
14
  typealias Completion = (Result<Any, CraftBluetoothError>) -> Void
12
15
  static let shared = CraftBluetooth()
@@ -327,3 +330,12 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
327
330
  peripheral = nil; writer = nil; notifier = nil; connected = false
328
331
  }
329
332
  }
333
+ #else
334
+ final class CraftBluetooth {
335
+ typealias Completion = (Result<Any, CraftBluetoothError>) -> Void
336
+ static let shared = CraftBluetooth()
337
+ func perform(method: String, args: [Any], completion: @escaping Completion) {
338
+ completion(.failure(CraftBluetoothError(message: "Bluetooth was not enabled when this app was built", code: "CAPABILITY_DISABLED")))
339
+ }
340
+ }
341
+ #endif
@@ -1,3 +1,4 @@
1
+ #if CRAFT_PERMISSION_HEALTH
1
2
  import CoreLocation
2
3
  import Foundation
3
4
  import HealthKit
@@ -127,6 +128,8 @@ final class CraftHealth {
127
128
  resumeWorkout(completion: completion)
128
129
  case "endWorkout":
129
130
  endWorkout(completion: completion)
131
+ case "discardWorkout":
132
+ discardWorkout(completion: completion)
130
133
  default:
131
134
  completion(.failure(CraftHealthError(message: "Unsupported Health method", code: "UNKNOWN_ACTION")))
132
135
  }
@@ -925,7 +928,13 @@ final class CraftHealth {
925
928
  do {
926
929
  let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
927
930
  let builder = session.associatedWorkoutBuilder()
928
- let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
931
+ let id = UUID().uuidString.lowercased()
932
+ let delegate = CraftHealthWorkoutDelegate(id: { id }) { [weak self] detail in
933
+ self?.onMain { [weak self] in
934
+ guard let self, self.liveWorkoutID == id else { return }
935
+ self.sendWorkout(detail)
936
+ }
937
+ }
929
938
  let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
930
939
  session.delegate = delegate
931
940
  builder.delegate = metricsDelegate
@@ -935,15 +944,18 @@ final class CraftHealth {
935
944
  liveWorkoutBuilder = builder
936
945
  liveWorkoutDelegate = delegate
937
946
  liveWorkoutBuilderDelegate = metricsDelegate
938
- liveWorkoutID = UUID().uuidString.lowercased()
947
+ liveWorkoutID = id
939
948
  liveWorkoutType = type
940
949
  liveWorkoutStart = Date()
941
- let id = liveWorkoutID!
942
950
  let start = liveWorkoutStart!
943
951
  session.startActivity(with: start)
944
952
  builder.beginCollection(withStart: start) { [weak self] success, error in
945
953
  DispatchQueue.main.async {
946
954
  guard let self else { return }
955
+ guard self.liveWorkoutID == id else {
956
+ completion(.failure(CraftHealthError(message: "Live workout was discarded while starting", code: "WORKOUT_CANCELLED")))
957
+ return
958
+ }
947
959
  guard success else {
948
960
  self.clearLiveWorkout()
949
961
  completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not start")))
@@ -980,16 +992,24 @@ final class CraftHealth {
980
992
 
981
993
  func endWorkout(completion rawCompletion: @escaping Completion) {
982
994
  let completion = completeOnMain(rawCompletion)
995
+ guard !liveWorkoutEnding else {
996
+ completion(.failure(CraftHealthError(message: "Live workout is already being saved", code: "WORKOUT_ENDING")))
997
+ return
998
+ }
983
999
  guard #available(iOS 26.0, *), let session = liveWorkoutSession as? HKWorkoutSession, let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder, let id = liveWorkoutID else {
984
1000
  completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
985
1001
  return
986
1002
  }
987
1003
  let end = Date()
1004
+ liveWorkoutEnding = true
988
1005
  session.end()
989
1006
  builder.endCollection(withEnd: end) { [weak self] success, error in
990
1007
  guard let self else { return }
991
1008
  guard success else {
992
- DispatchQueue.main.async { completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not end"))) }
1009
+ DispatchQueue.main.async {
1010
+ self.liveWorkoutEnding = false
1011
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Live workout could not end")))
1012
+ }
993
1013
  return
994
1014
  }
995
1015
  builder.finishWorkout { [weak self] workout, finishError in
@@ -1011,18 +1031,39 @@ final class CraftHealth {
1011
1031
  }
1012
1032
  }
1013
1033
 
1034
+ /// Stops collection without calling finishWorkout (which saves a workout).
1035
+ /// It does not delete health samples already persisted by HealthKit.
1036
+ func discardWorkout(completion rawCompletion: @escaping Completion) {
1037
+ let completion = completeOnMain(rawCompletion)
1038
+ guard !liveWorkoutEnding else {
1039
+ completion(.failure(CraftHealthError(message: "Live workout is already being saved", code: "WORKOUT_ENDING")))
1040
+ return
1041
+ }
1042
+ guard let id = liveWorkoutID, discardLiveWorkout(expectedID: id) else {
1043
+ completion(.failure(CraftHealthError(message: "No live workout is running", code: "NO_WORKOUT")))
1044
+ return
1045
+ }
1046
+ completion(.success(true))
1047
+ }
1048
+
1014
1049
  /// Only the Live Activity that started this runtime can release it. HQ
1015
1050
  /// persists its own final result, so collecting here must not duplicate it.
1016
1051
  func discardActivityWorkout(expectedID: String) {
1017
- guard #available(iOS 26.0, *), liveWorkoutID == expectedID,
1052
+ _ = discardLiveWorkout(expectedID: expectedID)
1053
+ }
1054
+
1055
+ private func discardLiveWorkout(expectedID: String) -> Bool {
1056
+ guard #available(iOS 26.0, *), !liveWorkoutEnding, liveWorkoutID == expectedID,
1018
1057
  let session = liveWorkoutSession as? HKWorkoutSession,
1019
- let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder else { return }
1058
+ let builder = liveWorkoutBuilder as? HKLiveWorkoutBuilder else { return false }
1020
1059
  session.end()
1021
1060
  builder.discardWorkout()
1022
1061
  clearLiveWorkout()
1062
+ return true
1023
1063
  }
1024
1064
 
1025
1065
  private func clearLiveWorkout() {
1066
+ liveWorkoutEnding = false
1026
1067
  liveWorkoutSession = nil
1027
1068
  liveWorkoutBuilder = nil
1028
1069
  liveWorkoutDelegate = nil
@@ -1034,7 +1075,7 @@ final class CraftHealth {
1034
1075
 
1035
1076
  @available(iOS 26.0, *)
1036
1077
  fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
1037
- guard let id = liveWorkoutID else { return }
1078
+ guard let id = liveWorkoutID, builder === liveWorkoutBuilder else { return }
1038
1079
  var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
1039
1080
  let bpm = HKUnit.count().unitDivided(by: .minute())
1040
1081
  if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
@@ -1049,7 +1090,10 @@ final class CraftHealth {
1049
1090
  let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
1050
1091
  detail["distanceMeters"] = value
1051
1092
  }
1052
- onMain { [weak self] in self?.sendWorkout(detail) }
1093
+ onMain { [weak self] in
1094
+ guard let self, self.liveWorkoutID == id else { return }
1095
+ self.sendWorkout(detail)
1096
+ }
1053
1097
  }
1054
1098
 
1055
1099
  private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
@@ -1090,6 +1134,7 @@ final class CraftHealth {
1090
1134
  // iOS 16.2; concrete HealthKit types are only touched behind the iOS 26
1091
1135
  // availability checks below.
1092
1136
  private var liveWorkoutSession: AnyObject?
1137
+ private var liveWorkoutEnding = false
1093
1138
  private var liveWorkoutBuilder: AnyObject?
1094
1139
  private var liveWorkoutDelegate: AnyObject?
1095
1140
  private var liveWorkoutBuilderDelegate: AnyObject?
@@ -1238,3 +1283,74 @@ final class CraftHealth {
1238
1283
  }
1239
1284
  #endif
1240
1285
  }
1286
+ #else
1287
+ import Foundation
1288
+ struct CraftHealthError: Error {
1289
+ let message: String
1290
+ var code: String = "CAPABILITY_DISABLED"
1291
+ }
1292
+ final class CraftHealth {
1293
+ typealias Completion = (Result<Any, CraftHealthError>) -> Void
1294
+ static let shared = CraftHealth()
1295
+ var pageSink: (([String: Any]) -> Bool)?
1296
+ var workoutSink: (([String: Any]) -> Bool)?
1297
+ func perform(method: String, args: [Any], completion rawCompletion: @escaping Completion) {
1298
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1299
+ }
1300
+ func requestAuthorization(types: [String], readOnly: Bool = false, completion rawCompletion: @escaping Completion) {
1301
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1302
+ }
1303
+ func getData(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
1304
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1305
+ }
1306
+ func saveData(type: String, value: Double, options: [String: Any], completion rawCompletion: @escaping Completion) {
1307
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1308
+ }
1309
+ func saveWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
1310
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1311
+ }
1312
+ func getWorkouts(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
1313
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1314
+ }
1315
+ func getDailyStatistics(type: String, startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
1316
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1317
+ }
1318
+ func getHeartRateSamples(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
1319
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1320
+ }
1321
+ func getSamples(type: String, startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
1322
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1323
+ }
1324
+ func getSleepIntervals(startDate: Double?, endDate: Double?, completion rawCompletion: @escaping Completion) {
1325
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1326
+ }
1327
+ func saveMindfulSession(body: [String: Any], completion rawCompletion: @escaping Completion) {
1328
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1329
+ }
1330
+ func getMindfulSessions(startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
1331
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1332
+ }
1333
+ func startWorkout(body: [String: Any], completion rawCompletion: @escaping Completion) {
1334
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1335
+ }
1336
+ func pauseWorkout(completion rawCompletion: @escaping Completion) {
1337
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1338
+ }
1339
+ func resumeWorkout(completion rawCompletion: @escaping Completion) {
1340
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1341
+ }
1342
+ func endWorkout(completion rawCompletion: @escaping Completion) {
1343
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1344
+ }
1345
+ func discardWorkout(completion rawCompletion: @escaping Completion) {
1346
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1347
+ }
1348
+ func startHeartRateUpdates(since: Double?, completion rawCompletion: @escaping Completion) {
1349
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1350
+ }
1351
+ func stopHeartRateUpdates(completion rawCompletion: @escaping Completion) {
1352
+ rawCompletion(.failure(CraftHealthError(message: "Health was not enabled when this app was built")))
1353
+ }
1354
+ func discardActivityWorkout(expectedID: String) {}
1355
+ }
1356
+ #endif
@@ -1,4 +1,6 @@
1
+ #if CRAFT_PERMISSION_BLUETOOTH
1
2
  import CoreBluetooth
3
+ #endif
2
4
  import Foundation
3
5
  import UIKit
4
6
 
@@ -120,6 +122,7 @@ struct CraftHeartRateMeasurement: Equatable {
120
122
  ///
121
123
  /// Everything runs on main: the central manager delivers there, and every
122
124
  /// caller hops there first.
125
+ #if CRAFT_PERMISSION_BLUETOOTH
123
126
  final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
124
127
  typealias Completion = (Result<Any, CraftHeartRateError>) -> Void
125
128
 
@@ -930,3 +933,13 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
930
933
  }
931
934
  }
932
935
  }
936
+ #else
937
+ final class CraftHeartRate {
938
+ typealias Completion = (Result<Any, CraftHeartRateError>) -> Void
939
+ static let shared = CraftHeartRate()
940
+ var pageSink: (([String: Any]) -> Bool)?
941
+ func perform(method: String, args: [Any], completion: @escaping Completion) {
942
+ completion(.failure(CraftHeartRateError(message: "Bluetooth was not enabled when this app was built", code: "CAPABILITY_DISABLED")))
943
+ }
944
+ }
945
+ #endif
@@ -1115,6 +1115,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1115
1115
  private var synchronizingTabs = false
1116
1116
  private var accessoryModel: CraftChrome.Tab?
1117
1117
  private let accessoryButton = UIButton(type: .system)
1118
+ private let accessoryBadge = UILabel()
1119
+ private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1118
1120
  private static let tabPictures = NSCache<NSURL, UIImage>()
1119
1121
  private var requestedTabPictures = Set<URL>()
1120
1122
  /// The path each native entry opened at.
@@ -1173,7 +1175,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1173
1175
  delegate = self
1174
1176
  if #available(iOS 18.0, *) { mode = .tabBar }
1175
1177
  #if compiler(>=6.2)
1176
- if #available(iOS 26.0, *) { tabBarMinimizeBehavior = .onScrollDown }
1178
+ if #available(iOS 26.0, *) { tabBarMinimizeBehavior = .never }
1177
1179
  #endif
1178
1180
  accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1179
1181
  accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
@@ -1327,28 +1329,59 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1327
1329
  if let model {
1328
1330
  var configuration = UIButton.Configuration.plain()
1329
1331
  configuration.image = UIImage(systemName: model.symbol)
1330
- configuration.title = model.displayTitle
1331
- configuration.subtitle = model.badge
1332
- configuration.imagePadding = 8
1332
+ configuration.title = model.iconOnly ? nil : model.displayTitle
1333
+ configuration.subtitle = model.iconOnly ? nil : model.badge
1334
+ configuration.imagePadding = model.iconOnly ? 0 : 8
1333
1335
  accessoryButton.configuration = configuration
1334
1336
  accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1335
1337
  accessoryButton.accessibilityValue = model.badge
1336
1338
  accessoryButton.accessibilityTraits = chrome.accessorySelected ? [.button, .selected] : [.button]
1337
1339
  }
1338
1340
  #if compiler(>=6.2)
1339
- if #available(iOS 26.0, *) {
1341
+ if #available(iOS 26.0, *), model?.iconOnly != true {
1342
+ if accessoryGlass.superview != nil {
1343
+ accessoryGlass.removeFromSuperview()
1344
+ accessoryBadge.removeFromSuperview()
1345
+ accessoryButton.removeFromSuperview()
1346
+ }
1340
1347
  if (model == nil) != (bottomAccessory == nil) {
1341
1348
  bottomAccessory = model.map { _ in UITabAccessory(contentView: accessoryButton) }
1342
1349
  }
1343
1350
  accessoryModel = model
1351
+ for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = 0 }
1344
1352
  return
1345
1353
  }
1346
1354
  #endif
1347
1355
  // Older systems have no bottom-accessory container. Keep the action
1348
1356
  // available above the system bar, without recreating the tab control.
1349
- if model != nil, accessoryButton.superview == nil { view.addSubview(accessoryButton) }
1357
+ #if compiler(>=6.2)
1358
+ if #available(iOS 26.0, *) {
1359
+ bottomAccessory = nil
1360
+ accessoryGlass.effect = UIGlassEffect()
1361
+ }
1362
+ #endif
1363
+ if model != nil, accessoryButton.superview !== view {
1364
+ accessoryButton.removeFromSuperview()
1365
+ view.addSubview(accessoryGlass)
1366
+ view.addSubview(accessoryButton)
1367
+ view.addSubview(accessoryBadge)
1368
+ }
1350
1369
  accessoryButton.isHidden = model == nil
1351
- accessoryButton.backgroundColor = config.resolvedBackgroundColor
1370
+ accessoryGlass.isHidden = model == nil
1371
+ accessoryGlass.isUserInteractionEnabled = false
1372
+ accessoryGlass.layer.cornerRadius = 24
1373
+ accessoryGlass.clipsToBounds = true
1374
+ accessoryButton.backgroundColor = .clear
1375
+ accessoryButton.layer.cornerRadius = 24
1376
+ accessoryBadge.text = model?.badge
1377
+ accessoryBadge.isHidden = model?.badge == nil || model?.iconOnly != true
1378
+ accessoryBadge.font = .systemFont(ofSize: 11, weight: .bold)
1379
+ accessoryBadge.textColor = .white
1380
+ accessoryBadge.backgroundColor = .systemRed
1381
+ accessoryBadge.textAlignment = .center
1382
+ accessoryBadge.layer.cornerRadius = 9
1383
+ accessoryBadge.clipsToBounds = true
1384
+ accessoryBadge.isAccessibilityElement = false
1352
1385
  accessoryModel = model
1353
1386
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
1354
1387
  view.setNeedsLayout()
@@ -1362,7 +1395,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1362
1395
  super.viewDidLayoutSubviews()
1363
1396
  guard hybrid.controller === self else { return }
1364
1397
  #if compiler(>=6.2)
1365
- if #available(iOS 26.0, *) {
1398
+ if #available(iOS 26.0, *), accessoryModel?.iconOnly != true {
1366
1399
  // UIKit already supplies the native safe area. This measurement is
1367
1400
  // only the web CSS contract, not another native bottom inset.
1368
1401
  let top = bottomAccessory.map { $0.contentView.convert($0.contentView.bounds, to: view).minY } ?? tabBar.frame.minY
@@ -1370,8 +1403,15 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1370
1403
  return
1371
1404
  }
1372
1405
  #endif
1373
- accessoryButton.frame = CGRect(x: 16, y: tabBar.frame.minY - 44, width: max(0, view.bounds.width - 32), height: 44)
1374
- CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + (accessoryModel == nil ? 0 : 44))
1406
+ let iconOnly = accessoryModel?.iconOnly == true
1407
+ let height: CGFloat = iconOnly ? 48 : 44
1408
+ let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
1409
+ let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1410
+ accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1411
+ accessoryGlass.frame = accessoryButton.frame
1412
+ let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1413
+ accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1414
+ CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + (accessoryModel == nil ? 0 : (iconOnly ? 56 : 44)))
1375
1415
  }
1376
1416
 
1377
1417
  private var webAttached = false
@@ -2235,10 +2275,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
2235
2275
  private func safeAreaForTabBar() -> CGFloat {
2236
2276
  // UIKit supplies the bar (and on iOS 26, its accessory) inset. Only
2237
2277
  // the older-system accessory above that bar needs an extra inset.
2278
+ let chrome = CraftChrome.shared
2279
+ if chrome.tabBarVisible && !chrome.tabs.isEmpty && chrome.tabAccessory?.iconOnly == true { return 56 }
2238
2280
  #if compiler(>=6.2)
2239
2281
  if #available(iOS 26.0, *) { return 0 }
2240
2282
  #endif
2241
- let chrome = CraftChrome.shared
2242
2283
  return chrome.tabBarVisible && !chrome.tabs.isEmpty && chrome.tabAccessory != nil ? 44 : 0
2243
2284
  }
2244
2285
 
@@ -106,6 +106,7 @@ enum CraftLiveActivities {
106
106
  /// final workout once. Never stop an unrelated HealthKit session.
107
107
  @available(iOS 16.2, *)
108
108
  @MainActor private static func startWorkout(_ activity: Activity<CraftActivityAttributes>, body: [String: Any], enabled: Bool) {
109
+ #if CRAFT_PERMISSION_HEALTH
109
110
  guard enabled, #available(iOS 26.0, *), let type = body["workoutType"] as? String,
110
111
  workoutOwner == nil, startingWorkout == nil,
111
112
  HKHealthStore().authorizationStatus(for: .workoutType()) == .sharingAuthorized else { return }
@@ -123,6 +124,7 @@ enum CraftLiveActivities {
123
124
  if activity.content.state.paused == true { CraftHealth.shared.pauseWorkout { _ in } }
124
125
  }
125
126
  }
127
+ #endif
126
128
  }
127
129
 
128
130
  @MainActor private static func stopWorkout(_ key: String) {
@@ -1,3 +1,4 @@
1
+ #if CRAFT_APPLE_MUSIC
1
2
  import AVFoundation
2
3
  import Foundation
3
4
  import MediaPlayer
@@ -422,3 +423,25 @@ final class CraftFakeMusicPlayer: CraftMusicPlayer {
422
423
  DispatchQueue.main.async { self.onChange?() }
423
424
  }
424
425
  }
426
+ #else
427
+ import Foundation
428
+
429
+ // Keep the bridge callable without linking the permission-dependent player.
430
+ // Runtime config changes cannot turn an API back on after it was compiled out.
431
+ struct CraftMusicError: Error {
432
+ let message: String
433
+ var code: String = "CAPABILITY_DISABLED"
434
+ }
435
+
436
+ final class CraftMusic {
437
+ typealias Listener = ([String: Any]) -> Void
438
+ static let shared = CraftMusic()
439
+ var authorization: String { "unsupported" }
440
+ func requestAuthorization(completion: @escaping (String) -> Void) { completion("unsupported") }
441
+ func snapshot() -> [String: Any] { ["available": false, "authorization": "unsupported"] }
442
+ func observe(_ listener: Listener?) {}
443
+ func control(_ command: String, restartAfter: Double?, completion: @escaping (Result<[String: Any], CraftMusicError>) -> Void) {
444
+ completion(.failure(CraftMusicError(message: "Apple Music was not enabled when this app was built")))
445
+ }
446
+ }
447
+ #endif
@@ -1,6 +1,8 @@
1
1
  import Foundation
2
2
  import AuthenticationServices
3
+ #if CRAFT_PERMISSION_BIOMETRIC
3
4
  import LocalAuthentication
5
+ #endif
4
6
  import PassKit
5
7
  import SQLite3
6
8
  import Security
@@ -27,7 +29,9 @@ enum CraftNativeActions {
27
29
  private static let databaseQueue = DispatchQueue(label: "dev.craft.native.database")
28
30
  private static let cancellationLock = NSLock()
29
31
  private static var cancelledRequests = Set<String>()
32
+ #if CRAFT_PERMISSION_BIOMETRIC
30
33
  private static var biometricContexts = [String: LAContext]()
34
+ #endif
31
35
  private static var fetchTasks = [String: URLSessionTask]()
32
36
  private static let fetchSession: URLSession = {
33
37
  let configuration = URLSessionConfiguration.default
@@ -248,7 +252,7 @@ enum CraftNativeActions {
248
252
  }
249
253
  return
250
254
  case ("Biometrics", "isAvailable"):
251
- sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
255
+ sync = config.enableBiometric ? .success(biometricAvailable()) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
252
256
  case ("Biometrics", "getBiometricType"):
253
257
  sync = config.enableBiometric ? .success(biometricType()) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
254
258
  case ("Biometrics", "authenticate"):
@@ -268,19 +272,19 @@ enum CraftNativeActions {
268
272
  // A person picks, so the bridge never times it out.
269
273
  let options = args.first as? [String: Any] ?? [:]
270
274
  DispatchQueue.main.async {
271
- CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
275
+ CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { completeFile(requestToken, $0, completion) }
272
276
  }
273
277
  return
274
278
  case ("Files", "pickVideo"):
275
279
  let options = args.first as? [String: Any] ?? [:]
276
280
  DispatchQueue.main.async {
277
- CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
281
+ CraftNativeFiles.pickVideo(options) { completeFile(requestToken, $0, completion) }
278
282
  }
279
283
  return
280
284
  case ("Files", "pickFile"):
281
285
  let options = args.first as? [String: Any] ?? [:]
282
286
  DispatchQueue.main.async {
283
- CraftNativeFiles.pickFile(options) { complete(requestToken, $0, completion) }
287
+ CraftNativeFiles.pickFile(options) { completeFile(requestToken, $0, completion) }
284
288
  }
285
289
  return
286
290
  case ("Files", "download"):
@@ -293,7 +297,7 @@ enum CraftNativeActions {
293
297
  let data = args.count > 1 ? args[1] : nil
294
298
  let options = args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:]
295
299
  DispatchQueue.global(qos: .userInitiated).async {
296
- complete(requestToken, CraftNativeFiles.write(name: name, data: data, options: options), completion)
300
+ completeFile(requestToken, CraftNativeFiles.write(name: name, data: data, options: options), completion)
297
301
  }
298
302
  return
299
303
  case ("Files", "remove"):
@@ -699,10 +703,14 @@ enum CraftNativeActions {
699
703
  static func cancel(requestToken: String) {
700
704
  cancellationLock.lock()
701
705
  cancelledRequests.insert(requestToken)
706
+ #if CRAFT_PERMISSION_BIOMETRIC
702
707
  let context = biometricContexts.removeValue(forKey: requestToken)
708
+ #endif
703
709
  let task = fetchTasks.removeValue(forKey: requestToken)
704
710
  cancellationLock.unlock()
711
+ #if CRAFT_PERMISSION_BIOMETRIC
705
712
  context?.invalidate()
713
+ #endif
706
714
  task?.cancel()
707
715
  CraftNativePasskeys.shared.cancel(requestToken: requestToken)
708
716
  }
@@ -861,7 +869,7 @@ enum CraftNativeActions {
861
869
  complete(requestToken, .success(CraftNativeFiles.refusedDownload(at: location, response: http)), completion)
862
870
  return
863
871
  }
864
- complete(requestToken, CraftNativeFiles.keepDownload(at: location, response: http, name: name), completion)
872
+ completeFile(requestToken, CraftNativeFiles.keepDownload(at: location, response: http, name: name), completion)
865
873
  }
866
874
  cancellationLock.lock()
867
875
  let alreadyCancelled = cancelledRequests.contains(requestToken)
@@ -949,16 +957,36 @@ enum CraftNativeActions {
949
957
  return url
950
958
  }
951
959
 
952
- private static func complete(
960
+ /// Delivery boundary for newly owned files from pickers, writes and downloads.
961
+ static func completeFile(
953
962
  _ requestToken: String,
954
963
  _ result: Result<Any, CraftNativeActionError>,
955
964
  _ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
965
+ ) {
966
+ complete(requestToken, result, completion, onCancel: {
967
+ // A cancelled caller never receives these descriptors and cannot
968
+ // remove them itself. Only discard newly created, app-owned files;
969
+ // persistent media-cache results use the ordinary completion path.
970
+ guard case .success(let value) = result else { return }
971
+ let files = (value as? [[String: Any]]) ?? (value as? [String: Any]).map { [$0] } ?? []
972
+ for file in files {
973
+ if let path = file["path"] as? String { CraftNativeFiles.remove(path) }
974
+ }
975
+ })
976
+ }
977
+
978
+ private static func complete(
979
+ _ requestToken: String,
980
+ _ result: Result<Any, CraftNativeActionError>,
981
+ _ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void,
982
+ onCancel: (() -> Void)? = nil
956
983
  ) {
957
984
  DispatchQueue.main.async {
958
985
  cancellationLock.lock()
959
986
  let cancelled = cancelledRequests.remove(requestToken) != nil
960
987
  cancellationLock.unlock()
961
- if !cancelled { completion(result) }
988
+ if cancelled { onCancel?() }
989
+ else { completion(result) }
962
990
  }
963
991
  }
964
992
 
@@ -1093,13 +1121,24 @@ enum CraftNativeActions {
1093
1121
  }
1094
1122
  }
1095
1123
 
1124
+ #if CRAFT_PERMISSION_BIOMETRIC
1096
1125
  private static func biometricContext() -> LAContext {
1097
1126
  let context = LAContext()
1098
1127
  context.localizedCancelTitle = "Cancel"
1099
1128
  return context
1100
1129
  }
1130
+ #endif
1131
+
1132
+ private static func biometricAvailable() -> Bool {
1133
+ #if CRAFT_PERMISSION_BIOMETRIC
1134
+ return biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
1135
+ #else
1136
+ return false
1137
+ #endif
1138
+ }
1101
1139
 
1102
1140
  private static func biometricType() -> String {
1141
+ #if CRAFT_PERMISSION_BIOMETRIC
1103
1142
  let context = biometricContext()
1104
1143
  _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
1105
1144
  switch context.biometryType {
@@ -1107,6 +1146,9 @@ enum CraftNativeActions {
1107
1146
  case .touchID: return "touchId"
1108
1147
  default: return "none"
1109
1148
  }
1149
+ #else
1150
+ return "none"
1151
+ #endif
1110
1152
  }
1111
1153
 
1112
1154
  private static func authenticate(
@@ -1115,6 +1157,7 @@ enum CraftNativeActions {
1115
1157
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
1116
1158
  ) {
1117
1159
  let reason = (args.first as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Authenticate to continue"
1160
+ #if CRAFT_PERMISSION_BIOMETRIC
1118
1161
  let context = biometricContext()
1119
1162
  cancellationLock.lock()
1120
1163
  biometricContexts[requestToken] = context
@@ -1126,6 +1169,9 @@ enum CraftNativeActions {
1126
1169
  if result { complete(requestToken, .success(true), completion) }
1127
1170
  else { complete(requestToken, failure("AUTHENTICATION_FAILED", error?.localizedDescription ?? "Biometric authentication failed"), completion) }
1128
1171
  }
1172
+ #else
1173
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Biometrics were not enabled when this app was built"), completion)
1174
+ #endif
1129
1175
  }
1130
1176
 
1131
1177
  private static func database(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
@@ -133,6 +133,7 @@ enum CraftNativeFiles {
133
133
  }
134
134
 
135
135
  if source == "camera" {
136
+ #if CRAFT_PERMISSION_CAMERA
136
137
  guard cameraEnabled else {
137
138
  done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera is disabled")))
138
139
  return
@@ -150,6 +151,9 @@ enum CraftNativeFiles {
150
151
  activePicker = nil
151
152
  done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
152
153
  }
154
+ #else
155
+ done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera was not enabled when this app was built")))
156
+ #endif
153
157
  return
154
158
  }
155
159
  guard source == "library" else {
@@ -247,6 +251,7 @@ enum CraftNativeFiles {
247
251
  }
248
252
  }
249
253
 
254
+ #if CRAFT_PERMISSION_CAMERA
250
255
  private final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
251
256
  let maxPixels: Int
252
257
  let quality: Double
@@ -287,6 +292,8 @@ enum CraftNativeFiles {
287
292
  }
288
293
  }
289
294
 
295
+ #endif
296
+
290
297
  // MARK: Picking a video
291
298
 
292
299
  /// System pickers grant access to just the selected video. Copy it while