@craft-native/ios 0.0.163 → 0.0.164
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/README.md +6 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +31 -0
- package/templates/CraftHealth.swift +48 -2
- package/templates/CraftHybrid.swift +49 -0
- package/templates/CraftNativeActions.swift +111 -1
- package/templates/CraftNativeFiles.swift +107 -0
- package/templates/CraftNativeScreen.swift +34 -3
package/README.md
CHANGED
|
@@ -148,6 +148,8 @@ The iOS host uses the shared layout ABI when it is available and retains a
|
|
|
148
148
|
local fallback for older runtimes. Both paths implement wrapped-line and grid
|
|
149
149
|
`alignContent`, including `space-evenly`, so a missing optional shared symbol
|
|
150
150
|
does not change placement semantics.
|
|
151
|
+
Reverse flex directions also retain wrapped line order on the cross axis while
|
|
152
|
+
reversing each line's main-axis children, matching Android's native host.
|
|
151
153
|
|
|
152
154
|
For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
|
|
153
155
|
|
|
@@ -405,6 +407,10 @@ const workouts = await health.getWorkouts({ startDate: Date.now() - 30 * 8640000
|
|
|
405
407
|
const hrv = await health.getDailyStatistics('heartRateVariability', { startDate: Date.now() - 30 * 86400000 })
|
|
406
408
|
// What a food log wrote (MyFitnessPal, Lose It!...): kcal, then grams of protein, carbohydrate and fat, summed per day.
|
|
407
409
|
const eaten = await health.getDailyStatistics('dietaryEnergy', { startDate: Date.now() - 7 * 86400000 })
|
|
410
|
+
// Food logs can write a quantity too; request write access first. The result
|
|
411
|
+
// contains HealthKit's sample id, and the date range defaults to this instant.
|
|
412
|
+
await health.requestAuthorization(['dietaryEnergy', 'dietaryProtein'], { write: true })
|
|
413
|
+
const sample = await health.saveData('dietaryProtein', 28, { startDate: mealStart, endDate: mealEnd })
|
|
408
414
|
// Every heart-rate reading in a range (bpm, start/end, source and device), oldest first.
|
|
409
415
|
const readings = await health.getHeartRateSamples({ startDate: Date.now() - 86400000 })
|
|
410
416
|
// The night as Apple Health recorded it: in bed, awake, and each asleep stage.
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -738,6 +738,18 @@ final class CraftChrome: ObservableObject {
|
|
|
738
738
|
emit("craftTabSelect", ["id": tab.id])
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/// A completed horizontal swipe selects the adjacent tab. The shell owns
|
|
742
|
+
/// this path so it works over both native screens and the shared WebView.
|
|
743
|
+
/// The gesture recognizer yields before calling this for a screen-owned
|
|
744
|
+
/// horizontal scroll or custom pager.
|
|
745
|
+
func swipe(to id: String) {
|
|
746
|
+
guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
|
|
747
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
748
|
+
UISelectionFeedbackGenerator().selectionChanged()
|
|
749
|
+
guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
|
|
750
|
+
emit("craftTabSelect", ["id": id])
|
|
751
|
+
}
|
|
752
|
+
|
|
741
753
|
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
742
754
|
/// its tab rather than springing there, as UIKit's own controls do.
|
|
743
755
|
static func motion(_ animation: Animation) -> Animation? {
|
|
@@ -2514,6 +2526,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2514
2526
|
} else {
|
|
2515
2527
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2516
2528
|
}
|
|
2529
|
+
case "saveHealthData":
|
|
2530
|
+
if config.enableHealthKit {
|
|
2531
|
+
guard let dataType = body["type"] as? String,
|
|
2532
|
+
let value = (body["value"] as? NSNumber)?.doubleValue else {
|
|
2533
|
+
rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
|
|
2534
|
+
break
|
|
2535
|
+
}
|
|
2536
|
+
CraftHealth.shared.saveData(
|
|
2537
|
+
type: dataType,
|
|
2538
|
+
value: value,
|
|
2539
|
+
options: body
|
|
2540
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
2541
|
+
} else {
|
|
2542
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2543
|
+
}
|
|
2517
2544
|
case "saveHealthWorkout":
|
|
2518
2545
|
if config.enableHealthKit {
|
|
2519
2546
|
saveHealthWorkout(body: body, callbackId: callbackId)
|
|
@@ -5077,6 +5104,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5077
5104
|
options = options || {};
|
|
5078
5105
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
5079
5106
|
},
|
|
5107
|
+
saveData: function(type, value, options) {
|
|
5108
|
+
options = options || {};
|
|
5109
|
+
return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
|
|
5110
|
+
},
|
|
5080
5111
|
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
5081
5112
|
getWorkouts: function(options) {
|
|
5082
5113
|
options = options || {};
|
|
@@ -32,6 +32,13 @@ final class CraftHealth {
|
|
|
32
32
|
case "getData":
|
|
33
33
|
guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
|
|
34
34
|
getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
|
|
35
|
+
case "saveData":
|
|
36
|
+
guard let type = args.first as? String,
|
|
37
|
+
args.count > 1,
|
|
38
|
+
let value = (args[1] as? NSNumber)?.doubleValue else {
|
|
39
|
+
return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
|
|
40
|
+
}
|
|
41
|
+
saveData(type: type, value: value, options: options(2), completion: completion)
|
|
35
42
|
case "saveWorkout":
|
|
36
43
|
saveWorkout(body: options(0), completion: completion)
|
|
37
44
|
case "getWorkouts":
|
|
@@ -120,12 +127,15 @@ final class CraftHealth {
|
|
|
120
127
|
}
|
|
121
128
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
122
129
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
123
|
-
// writes to Apple Health
|
|
130
|
+
// writes to Apple Health. Request the write grant too unless
|
|
131
|
+
// the caller explicitly asked for a read-only authorization.
|
|
124
132
|
if let dietaryType = Self.healthQuantity(type)?.type {
|
|
125
133
|
readTypes.insert(dietaryType)
|
|
134
|
+
if !readOnly { shareTypes.insert(dietaryType) }
|
|
126
135
|
}
|
|
127
136
|
default:
|
|
128
|
-
|
|
137
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
138
|
+
return
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -179,6 +189,42 @@ final class CraftHealth {
|
|
|
179
189
|
healthStore.execute(query)
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
/// Save one quantity sample, primarily a food-log entry. Dates are epoch
|
|
193
|
+
/// milliseconds; an omitted range records an instantaneous sample now.
|
|
194
|
+
func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
|
|
195
|
+
guard let healthStore = healthStore else {
|
|
196
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
guard value.isFinite, value >= 0 else {
|
|
200
|
+
completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
204
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
|
|
208
|
+
let endValue = options["endDate"] as? Double ?? startValue
|
|
209
|
+
guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
|
|
210
|
+
completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
let sample = HKQuantitySample(
|
|
214
|
+
type: qType,
|
|
215
|
+
quantity: HKQuantity(unit: spec.unit, doubleValue: value),
|
|
216
|
+
start: Date(timeIntervalSince1970: startValue / 1000),
|
|
217
|
+
end: Date(timeIntervalSince1970: endValue / 1000)
|
|
218
|
+
)
|
|
219
|
+
healthStore.save(sample) { success, error in
|
|
220
|
+
if success {
|
|
221
|
+
completion(.success(["id": sample.uuid.uuidString]))
|
|
222
|
+
} else {
|
|
223
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
func saveWorkout(body: [String: Any], completion: @escaping Completion) {
|
|
183
229
|
guard let healthStore = healthStore else {
|
|
184
230
|
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
@@ -1004,6 +1004,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1004
1004
|
private var openingWeb = false
|
|
1005
1005
|
private var firstFrameLogged = false
|
|
1006
1006
|
private var tabBarHeight: CGFloat = 0
|
|
1007
|
+
private var tabSwipeRecognizers: [UISwipeGestureRecognizer] = []
|
|
1007
1008
|
|
|
1008
1009
|
init(config: CraftConfig) {
|
|
1009
1010
|
self.config = config
|
|
@@ -1033,6 +1034,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1033
1034
|
let tabs = hybrid.tabIds
|
|
1034
1035
|
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
1035
1036
|
show(tab: start, rootPath: hybrid.startPath)
|
|
1037
|
+
installTabSwipeGestures()
|
|
1036
1038
|
if hybrid.hasWeb && hybrid.startsNative {
|
|
1037
1039
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
1038
1040
|
}
|
|
@@ -1043,6 +1045,42 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1043
1045
|
|
|
1044
1046
|
private var webAttached = false
|
|
1045
1047
|
|
|
1048
|
+
private func installTabSwipeGestures() {
|
|
1049
|
+
guard tabSwipeRecognizers.isEmpty else { return }
|
|
1050
|
+
for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
|
|
1051
|
+
let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
|
|
1052
|
+
recognizer.direction = direction
|
|
1053
|
+
recognizer.delegate = self
|
|
1054
|
+
recognizer.cancelsTouchesInView = false
|
|
1055
|
+
recognizer.delaysTouchesBegan = false
|
|
1056
|
+
view.addGestureRecognizer(recognizer)
|
|
1057
|
+
tabSwipeRecognizers.append(recognizer)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
|
|
1062
|
+
let tabs = hybrid.tabIds
|
|
1063
|
+
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
1064
|
+
let next = recognizer.direction == .left ? index + 1 : index - 1
|
|
1065
|
+
guard tabs.indices.contains(next) else { return }
|
|
1066
|
+
CraftChrome.shared.swipe(to: tabs[next])
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
|
|
1070
|
+
let local = root.convert(point, from: view)
|
|
1071
|
+
guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
|
|
1072
|
+
var candidate: UIView? = hit
|
|
1073
|
+
while let current = candidate {
|
|
1074
|
+
if let scroll = current as? UIScrollView,
|
|
1075
|
+
scroll.isScrollEnabled,
|
|
1076
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1077
|
+
return true
|
|
1078
|
+
}
|
|
1079
|
+
candidate = current.superview
|
|
1080
|
+
}
|
|
1081
|
+
return false
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1046
1084
|
/// The web view joins the screen, behind the native screens, and loads.
|
|
1047
1085
|
private func attachWeb() {
|
|
1048
1086
|
guard let webHost, !webAttached else { return }
|
|
@@ -1481,8 +1519,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1481
1519
|
}
|
|
1482
1520
|
|
|
1483
1521
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1522
|
+
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1523
|
+
guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
|
|
1524
|
+
if let native = top as? CraftNativeScreenController,
|
|
1525
|
+
!native.tabSwipeEnabled || !native.contentSwipeBack { return false }
|
|
1526
|
+
return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
|
|
1527
|
+
}
|
|
1484
1528
|
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1485
1529
|
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1530
|
+
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1531
|
+
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1532
|
+
!native.contentSwipeBack {
|
|
1533
|
+
return false
|
|
1534
|
+
}
|
|
1486
1535
|
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1487
1536
|
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1488
1537
|
nativeScreenContainsHorizontalScroll(native) {
|
|
@@ -40,7 +40,7 @@ enum CraftNativeActions {
|
|
|
40
40
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
41
41
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
42
42
|
// as it arrives) need nothing the app has to declare.
|
|
43
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking"]
|
|
43
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge"]
|
|
44
44
|
if config.enableBiometric { values.append("biometric") }
|
|
45
45
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
46
46
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -76,6 +76,9 @@ enum CraftNativeActions {
|
|
|
76
76
|
// runs out of process and asks no permission. Taking a photo needs
|
|
77
77
|
// the camera, which the app declares.
|
|
78
78
|
values.append("files")
|
|
79
|
+
// Video and audio kept on the phone for offline (CraftNativeMedia),
|
|
80
|
+
// which the native Video element plays in place of the address.
|
|
81
|
+
values.append("media")
|
|
79
82
|
if config.enableCamera { values.append("camera") }
|
|
80
83
|
return values
|
|
81
84
|
}
|
|
@@ -190,6 +193,19 @@ enum CraftNativeActions {
|
|
|
190
193
|
return
|
|
191
194
|
case ("Files", "remove"):
|
|
192
195
|
sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
|
|
196
|
+
case ("Media", "keep"):
|
|
197
|
+
keepMedia(requestToken: requestToken, args: args, completion: completion)
|
|
198
|
+
return
|
|
199
|
+
case ("Media", "path"):
|
|
200
|
+
sync = .success(CraftNativeMedia.local(args.first as? String ?? "")?.path ?? NSNull())
|
|
201
|
+
case ("Media", "prune"):
|
|
202
|
+
let keep = (args.first as? [Any] ?? []).compactMap { $0 as? String }
|
|
203
|
+
DispatchQueue.global(qos: .utility).async {
|
|
204
|
+
complete(requestToken, .success(CraftNativeMedia.prune(keep: keep)), completion)
|
|
205
|
+
}
|
|
206
|
+
return
|
|
207
|
+
case ("Media", "usage"):
|
|
208
|
+
sync = .success(CraftNativeMedia.usage())
|
|
193
209
|
case ("Lifecycle", "getState"):
|
|
194
210
|
sync = .success(currentAppState())
|
|
195
211
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -440,6 +456,19 @@ enum CraftNativeActions {
|
|
|
440
456
|
complete(requestToken, .success(true), completion)
|
|
441
457
|
}
|
|
442
458
|
return
|
|
459
|
+
case ("Badge", "set"):
|
|
460
|
+
guard let value = args.first as? NSNumber,
|
|
461
|
+
value.doubleValue.isFinite,
|
|
462
|
+
value.doubleValue >= 0,
|
|
463
|
+
value.doubleValue.rounded() == value.doubleValue else {
|
|
464
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Badge.set needs a non-negative whole number"), completion)
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
setBadgeCount(value.intValue) { complete(requestToken, $0, completion) }
|
|
468
|
+
return
|
|
469
|
+
case ("Badge", "clear"):
|
|
470
|
+
setBadgeCount(0) { complete(requestToken, $0, completion) }
|
|
471
|
+
return
|
|
443
472
|
case ("Notifications", _):
|
|
444
473
|
guard config.enableLocalNotifications else {
|
|
445
474
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
|
|
@@ -614,6 +643,63 @@ enum CraftNativeActions {
|
|
|
614
643
|
if !alreadyCancelled { task.resume() }
|
|
615
644
|
}
|
|
616
645
|
|
|
646
|
+
/// `craft.media.keep`: the file at an address kept on the phone for
|
|
647
|
+
/// offline, fetched once (a copy already there answers at once, and two
|
|
648
|
+
/// screens asking at the same time share one download). A refused answer
|
|
649
|
+
/// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
|
|
650
|
+
private static func keepMedia(
|
|
651
|
+
requestToken: String,
|
|
652
|
+
args: [Any],
|
|
653
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
654
|
+
) {
|
|
655
|
+
guard let address = args.first as? String,
|
|
656
|
+
let url = URL(string: address),
|
|
657
|
+
let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
|
|
658
|
+
url.host?.isEmpty == false else {
|
|
659
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep needs an absolute http or https URL"), completion)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
if let kept = CraftNativeMedia.local(address) {
|
|
663
|
+
complete(requestToken, .success(CraftNativeMedia.describe(address, kept)), completion)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
667
|
+
var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
|
|
668
|
+
request.httpMethod = "GET"
|
|
669
|
+
if let headers = options["headers"] as? [String: Any] {
|
|
670
|
+
for (name, value) in headers {
|
|
671
|
+
guard let value = value as? String else {
|
|
672
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep header values must be strings"), completion)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
|
|
679
|
+
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
680
|
+
cancellationLock.lock()
|
|
681
|
+
fetchTasks.removeValue(forKey: requestToken)
|
|
682
|
+
cancellationLock.unlock()
|
|
683
|
+
if let error = error {
|
|
684
|
+
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
685
|
+
CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
guard let http = response as? HTTPURLResponse, let location else {
|
|
689
|
+
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
// The system deletes `location` once this returns: move it now.
|
|
693
|
+
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
694
|
+
}
|
|
695
|
+
cancellationLock.lock()
|
|
696
|
+
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
697
|
+
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
698
|
+
cancellationLock.unlock()
|
|
699
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
700
|
+
else { task.resume() }
|
|
701
|
+
}
|
|
702
|
+
|
|
617
703
|
private static func claimInitialDeepLink() -> URL? {
|
|
618
704
|
cancellationLock.lock()
|
|
619
705
|
defer { cancellationLock.unlock() }
|
|
@@ -920,6 +1006,30 @@ enum CraftNativeActions {
|
|
|
920
1006
|
}
|
|
921
1007
|
}
|
|
922
1008
|
|
|
1009
|
+
/// Set the app icon badge without requesting notification authorization.
|
|
1010
|
+
/// Native screens use this same permission-free path as the page bridge.
|
|
1011
|
+
private static func setBadgeCount(
|
|
1012
|
+
_ count: Int,
|
|
1013
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
1014
|
+
) {
|
|
1015
|
+
DispatchQueue.main.async {
|
|
1016
|
+
if #available(iOS 16.0, *) {
|
|
1017
|
+
UNUserNotificationCenter.current().setBadgeCount(count) { error in
|
|
1018
|
+
DispatchQueue.main.async {
|
|
1019
|
+
if let error {
|
|
1020
|
+
completion(.failure(CraftNativeActionError(code: "NATIVE_CALL_FAILED", message: error.localizedDescription)))
|
|
1021
|
+
} else {
|
|
1022
|
+
completion(.success(true))
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
UIApplication.shared.applicationIconBadgeNumber = count
|
|
1028
|
+
completion(.success(true))
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
923
1033
|
static func deepLinkData(_ url: URL) -> [String: Any] {
|
|
924
1034
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
925
1035
|
return [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import CryptoKit
|
|
1
2
|
import Foundation
|
|
2
3
|
import ImageIO
|
|
3
4
|
import PhotosUI
|
|
@@ -375,3 +376,109 @@ enum CraftNativeFiles {
|
|
|
375
376
|
return ["ok": false, "status": response.statusCode, "body": text]
|
|
376
377
|
}
|
|
377
378
|
}
|
|
379
|
+
|
|
380
|
+
// `craft.media` on a native screen: video and audio kept on the phone for
|
|
381
|
+
// when there is no signal (a gym's basement, a plane). Not tmp/craft-files,
|
|
382
|
+
// which iOS and the sweep above both empty: Application Support/craft-media,
|
|
383
|
+
// left out of iCloud backups because every file can be fetched again. A file
|
|
384
|
+
// is named by its address's SHA-256, so the native `Video` element finds the
|
|
385
|
+
// copy of the URL it was given and plays it instead, with no lookup in
|
|
386
|
+
// JavaScript. The screen decides what to keep (`keep`) and what to drop
|
|
387
|
+
// (`prune`); nothing is evicted behind its back.
|
|
388
|
+
enum CraftNativeMedia {
|
|
389
|
+
static var root: URL {
|
|
390
|
+
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
391
|
+
?? FileManager.default.temporaryDirectory
|
|
392
|
+
return base.appendingPathComponent("craft-media", isDirectory: true)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private static let lock = NSLock()
|
|
396
|
+
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
397
|
+
private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
|
|
398
|
+
|
|
399
|
+
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
400
|
+
/// extension is the address's, because AVPlayer reads the type from it.
|
|
401
|
+
static func file(for address: String) -> URL {
|
|
402
|
+
let digest = SHA256.hash(data: Data(address.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
403
|
+
let ext = URL(string: address)?.pathExtension.lowercased() ?? ""
|
|
404
|
+
let safe = ext.range(of: "^[a-z0-9]{1,5}$", options: .regularExpression) != nil ? ext : "bin"
|
|
405
|
+
return root.appendingPathComponent("\(digest).\(safe)")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// The copy of `address` on this phone, or nil.
|
|
409
|
+
static func local(_ address: String) -> URL? {
|
|
410
|
+
guard !address.isEmpty else { return nil }
|
|
411
|
+
let url = file(for: address)
|
|
412
|
+
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
static func describe(_ address: String, _ url: URL) -> [String: Any] {
|
|
416
|
+
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
417
|
+
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Joins a fetch of `address` already under way; true when one was.
|
|
421
|
+
static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
|
|
422
|
+
lock.lock()
|
|
423
|
+
defer { lock.unlock() }
|
|
424
|
+
if inFlight[address] != nil {
|
|
425
|
+
inFlight[address]!.append(done)
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
inFlight[address] = [done]
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
433
|
+
lock.lock()
|
|
434
|
+
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
435
|
+
lock.unlock()
|
|
436
|
+
for done in waiting { done(result) }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// A finished download moved into place (atomically, so a half-written
|
|
440
|
+
/// file is never played), or a refusal with the server's status.
|
|
441
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
442
|
+
guard (200..<300).contains(response.statusCode) else {
|
|
443
|
+
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
444
|
+
}
|
|
445
|
+
let fm = FileManager.default
|
|
446
|
+
do {
|
|
447
|
+
var folder = root
|
|
448
|
+
if !fm.fileExists(atPath: folder.path) {
|
|
449
|
+
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
450
|
+
var values = URLResourceValues()
|
|
451
|
+
values.isExcludedFromBackup = true
|
|
452
|
+
try? folder.setResourceValues(values)
|
|
453
|
+
}
|
|
454
|
+
let target = file(for: address)
|
|
455
|
+
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
456
|
+
try fm.moveItem(at: location, to: staging)
|
|
457
|
+
_ = try? fm.removeItem(at: target)
|
|
458
|
+
try fm.moveItem(at: staging, to: target)
|
|
459
|
+
return .success(describe(address, target))
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
467
|
+
static func prune(keep: [String]) -> Int {
|
|
468
|
+
let wanted = Set(keep.map { file(for: $0).lastPathComponent })
|
|
469
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
470
|
+
var removed = 0
|
|
471
|
+
for url in files where !wanted.contains(url.lastPathComponent) {
|
|
472
|
+
if (try? FileManager.default.removeItem(at: url)) != nil { removed += 1 }
|
|
473
|
+
}
|
|
474
|
+
return removed
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// What the kept copies come to, in bytes, and how many there are.
|
|
478
|
+
static func usage() -> [String: Any] {
|
|
479
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey])) ?? []
|
|
480
|
+
let kept = files.filter { $0.pathExtension != "part" }
|
|
481
|
+
let bytes = kept.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) }
|
|
482
|
+
return ["files": kept.count, "bytes": bytes]
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
|
|
|
341
341
|
detachPlayer()
|
|
342
342
|
sourceURI = uri
|
|
343
343
|
loops = false
|
|
344
|
-
|
|
344
|
+
// The copy `craft.media.keep` put on the phone plays in place of
|
|
345
|
+
// the address, so a kept video plays with no signal.
|
|
346
|
+
let kept = CraftNativeMedia.local(uri)
|
|
347
|
+
guard let url = kept ?? URL(string: uri), !uri.isEmpty, kept != nil || url.scheme != nil else {
|
|
345
348
|
playerController.player = nil
|
|
349
|
+
// A path with no scheme ("/videos/a.mp4") has nowhere to load
|
|
350
|
+
// from on a native screen. Say so rather than stay black.
|
|
351
|
+
if !uri.isEmpty {
|
|
352
|
+
DispatchQueue.main.async { [weak self] in
|
|
353
|
+
guard let self, self.sourceURI == uri else { return }
|
|
354
|
+
self.fire("onError", ["message": "The video address is not a full URL: \(uri)"])
|
|
355
|
+
}
|
|
356
|
+
}
|
|
346
357
|
return
|
|
347
358
|
}
|
|
348
359
|
let next = AVPlayer(url: url)
|
|
@@ -1110,6 +1121,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1110
1121
|
}
|
|
1111
1122
|
if start < items.count { lines.append(start..<items.count) }
|
|
1112
1123
|
}
|
|
1124
|
+
// Reconciliation reverses the child order for the requested main
|
|
1125
|
+
// axis. A wrapped flex container must keep its lines in cross-axis
|
|
1126
|
+
// order, so restore the groups without changing each line's order.
|
|
1127
|
+
if reverse && wrap && lines.count > 1 { lines.reverse() }
|
|
1113
1128
|
|
|
1114
1129
|
var lineMains: [CGFloat] = []
|
|
1115
1130
|
var lineCrosses: [CGFloat] = []
|
|
@@ -1922,7 +1937,9 @@ private final class CraftNativeSvgView: UIView {
|
|
|
1922
1937
|
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
1923
1938
|
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
1924
1939
|
if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
|
|
1925
|
-
|
|
1940
|
+
// Computed style wins over the component prop, matching Android
|
|
1941
|
+
// and the browser's cascade when both provide opacity.
|
|
1942
|
+
if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
|
|
1926
1943
|
if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
|
|
1927
1944
|
if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
|
|
1928
1945
|
if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
|
|
@@ -2709,7 +2726,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2709
2726
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2710
2727
|
/// `craft.secureStorage.set` wrote.
|
|
2711
2728
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
2712
|
-
/// rightButtons })`; a right button's
|
|
2729
|
+
/// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
|
|
2730
|
+
/// tap is the event `navButton`.
|
|
2713
2731
|
///
|
|
2714
2732
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
2715
2733
|
/// these: each namespace is a property whose setter adds them back to the
|
|
@@ -2953,6 +2971,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2953
2971
|
private var navigationBarHidden: Bool?
|
|
2954
2972
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
2955
2973
|
private var tabBarHidden = false
|
|
2974
|
+
/// Whether iOS 26's content-wide back-swipe may begin on this screen.
|
|
2975
|
+
/// Setting `contentSwipeBack: false` leaves the 32-point edge gesture.
|
|
2976
|
+
private(set) var contentSwipeBack = true
|
|
2977
|
+
/// Whether the shell's horizontal tab switch gesture may begin here.
|
|
2978
|
+
/// This is separate so a screen can keep navigation back while owning its
|
|
2979
|
+
/// own horizontal pager or chart gesture.
|
|
2980
|
+
private(set) var tabSwipeEnabled = true
|
|
2956
2981
|
private var prefersLargeTitle: Bool?
|
|
2957
2982
|
|
|
2958
2983
|
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
@@ -2988,6 +3013,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2988
3013
|
CraftChrome.shared.setHiddenByScreen(hidden)
|
|
2989
3014
|
}
|
|
2990
3015
|
}
|
|
3016
|
+
if let contentSwipeBack = options["contentSwipeBack"] as? Bool {
|
|
3017
|
+
self.contentSwipeBack = contentSwipeBack
|
|
3018
|
+
}
|
|
3019
|
+
if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
|
|
3020
|
+
self.tabSwipeEnabled = tabSwipeEnabled
|
|
3021
|
+
}
|
|
2991
3022
|
if let hidden = options["hidden"] as? Bool {
|
|
2992
3023
|
navigationBarHidden = hidden
|
|
2993
3024
|
if navigationController?.topViewController === self {
|