craft-native 0.0.368 → 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.
- package/dist/android/src/index.js +0 -4
- package/dist/android/templates/CraftNativeCapabilities.kt.template +50 -28
- package/dist/android/templates/CraftNativeSounds.kt.template +44 -19
- package/dist/api/mobile.d.ts +3 -1
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +6 -2
- package/dist/ios/src/index.js +338 -179
- package/dist/ios/src/runtime-profile.d.ts +13 -0
- package/dist/ios/templates/CraftApp.swift +279 -3
- package/dist/ios/templates/CraftBluetooth.swift +12 -0
- package/dist/ios/templates/CraftHealth.swift +124 -8
- package/dist/ios/templates/CraftHeartRate.swift +13 -0
- package/dist/ios/templates/CraftHybrid.swift +52 -11
- package/dist/ios/templates/CraftLiveActivities.swift +2 -0
- package/dist/ios/templates/CraftMusic.swift +23 -0
- package/dist/ios/templates/CraftNativeActions.swift +54 -8
- package/dist/ios/templates/CraftNativeFiles.swift +7 -0
- package/dist/ios/templates/CraftNativeScreen.swift +155 -0
- package/dist/ios/templates/CraftNativeServices.swift +19 -13
- package/dist/ios/templates/CraftVoiceRecording.swift +13 -1
- package/dist/mobile.js +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 = .
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1374
|
-
|
|
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(
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|