craft-native 0.0.163 → 0.0.166
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/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/MainActivityNative.kt.template +516 -119
- package/dist/api/mobile.d.ts +34 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +1 -0
- package/dist/ios/src/index.js +138 -88
- package/dist/ios/src/source-audit.d.ts +8 -0
- package/dist/ios/templates/CraftApp.swift +94 -2
- package/dist/ios/templates/CraftHealth.swift +261 -40
- package/dist/ios/templates/CraftHeartRate.swift +34 -4
- package/dist/ios/templates/CraftHybrid.swift +174 -2
- package/dist/ios/templates/CraftNativeActions.swift +111 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +354 -59
- package/dist/mobile.js +35 -0
- package/package.json +1 -1
|
@@ -1004,6 +1004,8 @@ 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: [UIGestureRecognizer] = []
|
|
1008
|
+
private var activeTabSwipeTarget: String?
|
|
1007
1009
|
|
|
1008
1010
|
init(config: CraftConfig) {
|
|
1009
1011
|
self.config = config
|
|
@@ -1033,6 +1035,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1033
1035
|
let tabs = hybrid.tabIds
|
|
1034
1036
|
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
1035
1037
|
show(tab: start, rootPath: hybrid.startPath)
|
|
1038
|
+
installTabSwipeGestures()
|
|
1036
1039
|
if hybrid.hasWeb && hybrid.startsNative {
|
|
1037
1040
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
1038
1041
|
}
|
|
@@ -1043,6 +1046,151 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1043
1046
|
|
|
1044
1047
|
private var webAttached = false
|
|
1045
1048
|
|
|
1049
|
+
private func installTabSwipeGestures() {
|
|
1050
|
+
guard tabSwipeRecognizers.isEmpty else { return }
|
|
1051
|
+
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
|
|
1052
|
+
recognizer.delegate = self
|
|
1053
|
+
recognizer.cancelsTouchesInView = false
|
|
1054
|
+
recognizer.delaysTouchesBegan = false
|
|
1055
|
+
view.addGestureRecognizer(recognizer)
|
|
1056
|
+
tabSwipeRecognizers.append(recognizer)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
@objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
1060
|
+
let tabs = hybrid.tabIds
|
|
1061
|
+
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
1062
|
+
let translation = recognizer.translation(in: view).x
|
|
1063
|
+
let velocity = recognizer.velocity(in: view).x
|
|
1064
|
+
switch recognizer.state {
|
|
1065
|
+
case .changed:
|
|
1066
|
+
if activeTabSwipeTarget == nil {
|
|
1067
|
+
let direction = abs(translation) > 1 ? translation : velocity
|
|
1068
|
+
guard abs(direction) > 1 else { return }
|
|
1069
|
+
let next = direction < 0 ? index + 1 : index - 1
|
|
1070
|
+
guard tabs.indices.contains(next), beginInteractiveTabSwipe(to: tabs[next], direction: direction < 0 ? -1 : 1) else { return }
|
|
1071
|
+
activeTabSwipeTarget = tabs[next]
|
|
1072
|
+
}
|
|
1073
|
+
updateInteractiveTabSwipe(translation: translation)
|
|
1074
|
+
case .ended, .cancelled, .failed:
|
|
1075
|
+
guard let target = activeTabSwipeTarget else {
|
|
1076
|
+
guard recognizer.state == .ended else { return }
|
|
1077
|
+
let delta = abs(translation) >= 32 ? translation : velocity
|
|
1078
|
+
guard abs(delta) >= 32 else { return }
|
|
1079
|
+
let next = delta < 0 ? index + 1 : index - 1
|
|
1080
|
+
guard tabs.indices.contains(next) else { return }
|
|
1081
|
+
CraftChrome.shared.swipe(to: tabs[next])
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
activeTabSwipeTarget = nil
|
|
1085
|
+
let complete = recognizer.state == .ended && (abs(translation) >= max(32, view.bounds.width * 0.28) || abs(velocity) > 700)
|
|
1086
|
+
finishInteractiveTabSwipe(completed: complete) { [weak self] in
|
|
1087
|
+
guard complete else {
|
|
1088
|
+
CraftChrome.shared.cancelTabSwipe()
|
|
1089
|
+
return
|
|
1090
|
+
}
|
|
1091
|
+
CraftChrome.shared.commitTabSwipe(to: target)
|
|
1092
|
+
self?.view.layoutIfNeeded()
|
|
1093
|
+
}
|
|
1094
|
+
default:
|
|
1095
|
+
break
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/// Prepare a mounted target beside the current stack. Native roots render
|
|
1100
|
+
/// immediately; a web root borrows the shared WebView and stays
|
|
1101
|
+
/// transparent until its page route catches up. Two web roots still use
|
|
1102
|
+
/// the route-aware fallback because one WebView cannot render both pages.
|
|
1103
|
+
private func beginInteractiveTabSwipe(to id: String, direction: CGFloat) -> Bool {
|
|
1104
|
+
guard let current = visibleStack, let target = stacks[id],
|
|
1105
|
+
current.view !== target.view,
|
|
1106
|
+
!(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
|
|
1107
|
+
view.bounds.width > 0 else { return false }
|
|
1108
|
+
if let slot = target.topViewController as? CraftHybridWebSlot {
|
|
1109
|
+
// There is one shared WebView, so a web-to-web gesture cannot
|
|
1110
|
+
// render two pages at once. A native-to-web gesture can: move the
|
|
1111
|
+
// WebView into the hidden target slot and let its route catch up
|
|
1112
|
+
// while the slot follows the finger. It stays transparent until
|
|
1113
|
+
// the page reports that it reached the target path.
|
|
1114
|
+
slot.awaitingPage = true
|
|
1115
|
+
adoptWeb(into: slot, allowHiddenStack: true)
|
|
1116
|
+
webHost?.view.alpha = 0
|
|
1117
|
+
hybrid.navigatePage(slot.path) { [weak self, weak slot] depth in
|
|
1118
|
+
guard let self, let slot else { return }
|
|
1119
|
+
slot.depth = depth
|
|
1120
|
+
guard self.activeTabSwipeTarget == id,
|
|
1121
|
+
CraftHybridRoutes.normalize(self.hybrid.pagePath ?? "") == CraftHybridRoutes.normalize(slot.path) else { return }
|
|
1122
|
+
self.reveal(slot)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
target.view.isHidden = false
|
|
1126
|
+
let width = view.bounds.width
|
|
1127
|
+
target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
|
|
1128
|
+
view.bringSubviewToFront(target.view)
|
|
1129
|
+
CraftChrome.shared.beginTabSwipe(to: id)
|
|
1130
|
+
return true
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
private func updateInteractiveTabSwipe(translation: CGFloat) {
|
|
1134
|
+
guard let targetId = activeTabSwipeTarget, let current = visibleStack,
|
|
1135
|
+
let target = stacks[targetId] else { return }
|
|
1136
|
+
let width = max(1, view.bounds.width)
|
|
1137
|
+
let start = target.view.frame.minX >= width * 0.5 ? width : -width
|
|
1138
|
+
let offset = min(width, max(-width, translation))
|
|
1139
|
+
target.view.frame.origin.x = start + offset
|
|
1140
|
+
current.view.frame.origin.x = offset
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
|
|
1144
|
+
guard let targetId = activeTabSwipeTarget, let current = visibleStack,
|
|
1145
|
+
let target = stacks[targetId] else { completion(); return }
|
|
1146
|
+
let width = view.bounds.width
|
|
1147
|
+
let direction: CGFloat = target.view.frame.minX >= 0 ? 1 : -1
|
|
1148
|
+
let end = completed ? 0 : direction * width
|
|
1149
|
+
let duration = CraftChrome.motion(.easeOut(duration: completed ? 0.22 : 0.18)) == nil ? 0 : 0.22
|
|
1150
|
+
UIView.animate(withDuration: duration, animations: {
|
|
1151
|
+
target.view.frame.origin.x = end
|
|
1152
|
+
current.view.frame.origin.x = completed ? -direction * width : 0
|
|
1153
|
+
}, completion: { _ in
|
|
1154
|
+
if !completed {
|
|
1155
|
+
target.view.isHidden = true
|
|
1156
|
+
target.view.frame = self.view.bounds
|
|
1157
|
+
current.view.frame = self.view.bounds
|
|
1158
|
+
if target.topViewController is CraftHybridWebSlot,
|
|
1159
|
+
current.topViewController is CraftNativeScreenController {
|
|
1160
|
+
self.parkWeb()
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
completion()
|
|
1164
|
+
})
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
|
|
1168
|
+
let local = root.convert(point, from: view)
|
|
1169
|
+
guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
|
|
1170
|
+
// WKWebView keeps its scrolling UIScrollView below the hosting view
|
|
1171
|
+
// that owns the native slot, so it is not necessarily on the hit
|
|
1172
|
+
// view's superview chain. Treat an overflowing page as its own
|
|
1173
|
+
// horizontal gesture surface before the shell claims the swipe.
|
|
1174
|
+
if let webView = CraftChrome.shared.webView,
|
|
1175
|
+
hit === webView || hit.isDescendant(of: webView) || webView.isDescendant(of: hit) {
|
|
1176
|
+
let scroll = webView.scrollView
|
|
1177
|
+
if scroll.isScrollEnabled,
|
|
1178
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1179
|
+
return true
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
var candidate: UIView? = hit
|
|
1183
|
+
while let current = candidate {
|
|
1184
|
+
if let scroll = current as? UIScrollView,
|
|
1185
|
+
scroll.isScrollEnabled,
|
|
1186
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1187
|
+
return true
|
|
1188
|
+
}
|
|
1189
|
+
candidate = current.superview
|
|
1190
|
+
}
|
|
1191
|
+
return false
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1046
1194
|
/// The web view joins the screen, behind the native screens, and loads.
|
|
1047
1195
|
private func attachWeb() {
|
|
1048
1196
|
guard let webHost, !webAttached else { return }
|
|
@@ -1155,6 +1303,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1155
1303
|
}
|
|
1156
1304
|
currentTab = id
|
|
1157
1305
|
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
1306
|
+
nav.view.frame = view.bounds
|
|
1158
1307
|
view.bringSubviewToFront(nav.view)
|
|
1159
1308
|
if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
1160
1309
|
adoptWeb(into: slot)
|
|
@@ -1168,8 +1317,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1168
1317
|
pendingTab = nil
|
|
1169
1318
|
}
|
|
1170
1319
|
|
|
1171
|
-
func adoptWeb(into slot: CraftHybridWebSlot) {
|
|
1172
|
-
guard let webHost,
|
|
1320
|
+
func adoptWeb(into slot: CraftHybridWebSlot, allowHiddenStack: Bool = false) {
|
|
1321
|
+
guard let webHost,
|
|
1322
|
+
allowHiddenStack || slot.navigationController === visibleStack || visibleStack == nil else { return }
|
|
1173
1323
|
attachWeb()
|
|
1174
1324
|
moveWeb(to: slot, in: slot.view)
|
|
1175
1325
|
webHost.view.alpha = slot.awaitingPage ? 0 : 1
|
|
@@ -1295,6 +1445,13 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1295
1445
|
|
|
1296
1446
|
func pageNavigated(path: String, direction: String, depth: Int, byHost: Bool) {
|
|
1297
1447
|
let owner = CraftHybridRoutes.tab(owning: path, among: hybrid.tabIds)
|
|
1448
|
+
if let targetId = activeTabSwipeTarget,
|
|
1449
|
+
let target = stacks[targetId]?.topViewController as? CraftHybridWebSlot,
|
|
1450
|
+
owner == targetId || CraftHybridRoutes.normalize(path) == CraftHybridRoutes.normalize(target.path) {
|
|
1451
|
+
target.depth = depth
|
|
1452
|
+
target.awaitingPage = false
|
|
1453
|
+
reveal(target)
|
|
1454
|
+
}
|
|
1298
1455
|
if let pending = pendingTab, owner == pending.id || direction == "tab" {
|
|
1299
1456
|
show(tab: pending.id)
|
|
1300
1457
|
}
|
|
@@ -1481,10 +1638,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1481
1638
|
}
|
|
1482
1639
|
|
|
1483
1640
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1641
|
+
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1642
|
+
guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
|
|
1643
|
+
guard CraftChrome.shared.tabSwipeEnabled else { return false }
|
|
1644
|
+
if let pan = gestureRecognizer as? UIPanGestureRecognizer {
|
|
1645
|
+
let velocity = pan.velocity(in: view)
|
|
1646
|
+
guard abs(velocity.x) > abs(velocity.y) else { return false }
|
|
1647
|
+
}
|
|
1648
|
+
if let native = top as? CraftNativeScreenController,
|
|
1649
|
+
!native.tabSwipeEnabled || !native.contentSwipeBack { return false }
|
|
1650
|
+
return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
|
|
1651
|
+
}
|
|
1484
1652
|
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1485
1653
|
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1486
1654
|
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1487
1655
|
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1656
|
+
!native.contentSwipeBack {
|
|
1657
|
+
return false
|
|
1658
|
+
}
|
|
1659
|
+
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1488
1660
|
nativeScreenContainsHorizontalScroll(native) {
|
|
1489
1661
|
let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
|
|
1490
1662
|
if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }
|
|
@@ -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
|
+
}
|