craft-native 0.0.358 → 0.0.360
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 +1 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +184 -14
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +18 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +28 -1
- package/dist/android/templates/CraftNativeShell.kt.template +20 -5
- package/dist/android/templates/CraftNativeSounds.kt.template +260 -0
- package/dist/android/templates/MainActivityNative.kt.template +175 -3
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +3 -2
- package/dist/ios/templates/CraftBluetooth.swift +16 -4
- package/dist/ios/templates/CraftHybrid.swift +224 -15
- package/dist/ios/templates/CraftNativeActions.swift +50 -0
- package/dist/ios/templates/CraftNativeFiles.swift +5 -3
- package/dist/ios/templates/CraftNativeScreen.swift +203 -29
- package/dist/ios/templates/CraftNativeServices.swift +58 -10
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import Foundation
|
|
|
2
2
|
import SwiftUI
|
|
3
3
|
import UIKit
|
|
4
4
|
import WebKit
|
|
5
|
+
import Combine
|
|
5
6
|
|
|
6
7
|
// MARK: - Hybrid: web plus native screens
|
|
7
8
|
//
|
|
@@ -1098,7 +1099,7 @@ final class CraftNativeNavigationController: UINavigationController {
|
|
|
1098
1099
|
}
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1101
|
-
final class CraftHybridController:
|
|
1102
|
+
final class CraftHybridController: UITabBarController, UITabBarControllerDelegate, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
|
|
1102
1103
|
private let config: CraftConfig
|
|
1103
1104
|
private let hybrid = CraftHybrid.shared
|
|
1104
1105
|
/// The web view's host; nil in an all-native app, which never makes one.
|
|
@@ -1107,6 +1108,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1107
1108
|
private let parking = UIView()
|
|
1108
1109
|
private var stacks: [String: UINavigationController] = [:]
|
|
1109
1110
|
private var currentTab = ""
|
|
1111
|
+
private var tabPlaceholders: [String: UIViewController] = [:]
|
|
1112
|
+
private var systemTabItems: [String: UITabBarItem] = [:]
|
|
1113
|
+
private var chromeSubscription: AnyCancellable?
|
|
1114
|
+
private var chromeUpdatePending = false
|
|
1115
|
+
private var synchronizingTabs = false
|
|
1116
|
+
private var accessoryModel: CraftChrome.Tab?
|
|
1117
|
+
private let accessoryButton = UIButton(type: .system)
|
|
1118
|
+
private static let tabPictures = NSCache<NSURL, UIImage>()
|
|
1119
|
+
private var requestedTabPictures = Set<URL>()
|
|
1110
1120
|
/// The path each native entry opened at.
|
|
1111
1121
|
private var nativePaths: [ObjectIdentifier: String] = [:]
|
|
1112
1122
|
/// Each stack as it was after its last transition, to see what a pop removed.
|
|
@@ -1159,10 +1169,30 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1159
1169
|
|
|
1160
1170
|
override func viewDidLoad() {
|
|
1161
1171
|
super.viewDidLoad()
|
|
1172
|
+
hybrid.controller = self
|
|
1173
|
+
delegate = self
|
|
1174
|
+
if #available(iOS 18.0, *) { mode = .tabBar }
|
|
1175
|
+
#if compiler(>=6.2)
|
|
1176
|
+
if #available(iOS 26.0, *) { tabBarMinimizeBehavior = .onScrollDown }
|
|
1177
|
+
#endif
|
|
1178
|
+
accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
|
|
1179
|
+
accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
|
|
1180
|
+
chromeSubscription = CraftChrome.shared.objectWillChange.sink { [weak self] in
|
|
1181
|
+
guard let self, !self.chromeUpdatePending else { return }
|
|
1182
|
+
self.chromeUpdatePending = true
|
|
1183
|
+
// Published sends before its value changes; coalesce the complete
|
|
1184
|
+
// chrome transaction before updating UIKit's retained tab items.
|
|
1185
|
+
DispatchQueue.main.async { [weak self] in
|
|
1186
|
+
guard let self else { return }
|
|
1187
|
+
self.chromeUpdatePending = false
|
|
1188
|
+
guard self.hybrid.controller === self else { return }
|
|
1189
|
+
self.synchronizeSystemTabs()
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1162
1192
|
view.backgroundColor = config.resolvedBackgroundColor
|
|
1163
1193
|
parking.frame = view.bounds
|
|
1164
1194
|
parking.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1165
|
-
view.
|
|
1195
|
+
view.insertSubview(parking, at: 0)
|
|
1166
1196
|
|
|
1167
1197
|
// A native start screen draws first; the web view, whose creation
|
|
1168
1198
|
// costs a frame or more, starts loading straight after it. An
|
|
@@ -1185,6 +1215,165 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1185
1215
|
prepareAdjacentNativeTabs()
|
|
1186
1216
|
}
|
|
1187
1217
|
|
|
1218
|
+
/// UIKit owns the bar, selection indicator, badges and content safe area.
|
|
1219
|
+
/// Placeholders preserve lazy screen creation without inventing a second
|
|
1220
|
+
/// navigation stack when a tab is first selected or prepared for a swipe.
|
|
1221
|
+
private func synchronizeSystemTabs() {
|
|
1222
|
+
guard isViewLoaded, !synchronizingTabs else { return }
|
|
1223
|
+
synchronizingTabs = true
|
|
1224
|
+
defer { synchronizingTabs = false }
|
|
1225
|
+
let chrome = CraftChrome.shared
|
|
1226
|
+
let ids = hybrid.tabIds.isEmpty ? [currentTab] : hybrid.tabIds
|
|
1227
|
+
let controllers = ids.map { id -> UIViewController in
|
|
1228
|
+
let controller: UIViewController
|
|
1229
|
+
if let stack = stacks[id] { controller = stack }
|
|
1230
|
+
else if let placeholder = tabPlaceholders[id] { controller = placeholder }
|
|
1231
|
+
else {
|
|
1232
|
+
controller = UIViewController()
|
|
1233
|
+
tabPlaceholders[id] = controller
|
|
1234
|
+
}
|
|
1235
|
+
// An explicit retained item prevents UINavigationController from
|
|
1236
|
+
// adopting a newly loaded root screen's default tab item later.
|
|
1237
|
+
let item = systemTabItems[id] ?? UITabBarItem(title: nil, image: nil, tag: 0)
|
|
1238
|
+
systemTabItems[id] = item
|
|
1239
|
+
if controller.tabBarItem !== item { controller.tabBarItem = item }
|
|
1240
|
+
return controller
|
|
1241
|
+
}
|
|
1242
|
+
let existing = viewControllers ?? []
|
|
1243
|
+
if existing.count != controllers.count || zip(existing, controllers).contains(where: { $0 !== $1 }) {
|
|
1244
|
+
setViewControllers(controllers, animated: false)
|
|
1245
|
+
customizableViewControllers = []
|
|
1246
|
+
}
|
|
1247
|
+
// UIKit rebuilds item presentation when a lazy placeholder is replaced.
|
|
1248
|
+
// Apply accessibility metadata after installing the retained stacks so
|
|
1249
|
+
// the last prepared tab keeps the same identifier as every other tab.
|
|
1250
|
+
for (id, controller) in zip(ids, controllers) {
|
|
1251
|
+
guard let tab = chrome.tabs.first(where: { $0.id == id }) else { continue }
|
|
1252
|
+
let item = controller.tabBarItem!
|
|
1253
|
+
item.title = tab.displayTitle
|
|
1254
|
+
item.image = systemTabImage(tab, selected: false)
|
|
1255
|
+
item.selectedImage = systemTabImage(tab, selected: true)
|
|
1256
|
+
item.badgeValue = tab.badge
|
|
1257
|
+
item.accessibilityIdentifier = "craft-tab-\(tab.id)"
|
|
1258
|
+
item.accessibilityLabel = tab.accessibilityLabel ?? tab.displayTitle
|
|
1259
|
+
}
|
|
1260
|
+
if let committed = stacks[currentTab], controllers.contains(where: { $0 === committed }), selectedViewController !== committed {
|
|
1261
|
+
UIView.performWithoutAnimation {
|
|
1262
|
+
selectedViewController = committed
|
|
1263
|
+
view.layoutIfNeeded()
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
tabBar.tintColor = chrome.tint ?? view.tintColor
|
|
1267
|
+
let hidden = !chrome.tabBarVisible || chrome.tabs.isEmpty
|
|
1268
|
+
if #available(iOS 18.0, *) { setTabBarHidden(hidden, animated: false) }
|
|
1269
|
+
else { tabBar.isHidden = hidden }
|
|
1270
|
+
updateSystemAccessory(hidden: hidden)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private func systemTabImage(_ tab: CraftChrome.Tab, selected: Bool) -> UIImage? {
|
|
1274
|
+
if let address = tab.image, let url = URL(string: address), url.scheme == "https" {
|
|
1275
|
+
if let image = Self.tabPictures.object(forKey: url as NSURL) { return image }
|
|
1276
|
+
if requestedTabPictures.insert(url).inserted {
|
|
1277
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, response, _ in
|
|
1278
|
+
guard let data, let response = response as? HTTPURLResponse,
|
|
1279
|
+
(200..<300).contains(response.statusCode), let image = UIImage(data: data) else { return }
|
|
1280
|
+
DispatchQueue.main.async { [weak self] in
|
|
1281
|
+
let fitted = UIGraphicsImageRenderer(size: CGSize(width: 26, height: 26)).image { _ in
|
|
1282
|
+
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 26, height: 26)).addClip()
|
|
1283
|
+
let scale = max(26 / max(1, image.size.width), 26 / max(1, image.size.height))
|
|
1284
|
+
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
|
1285
|
+
image.draw(in: CGRect(x: (26 - size.width) / 2, y: (26 - size.height) / 2, width: size.width, height: size.height))
|
|
1286
|
+
}.withRenderingMode(.alwaysOriginal)
|
|
1287
|
+
Self.tabPictures.countLimit = 16
|
|
1288
|
+
Self.tabPictures.setObject(fitted, forKey: url as NSURL)
|
|
1289
|
+
guard let self, self.hybrid.controller === self else { return }
|
|
1290
|
+
self.synchronizeSystemTabs()
|
|
1291
|
+
}
|
|
1292
|
+
}.resume()
|
|
1293
|
+
}
|
|
1294
|
+
} else if let text = tab.iconText {
|
|
1295
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
1296
|
+
.font: UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold), .foregroundColor: UIColor.systemRed,
|
|
1297
|
+
]
|
|
1298
|
+
let label = text as NSString
|
|
1299
|
+
let size = label.size(withAttributes: attributes)
|
|
1300
|
+
return UIGraphicsImageRenderer(size: CGSize(width: min(48, max(1, size.width)), height: 26)).image { _ in
|
|
1301
|
+
label.draw(in: CGRect(x: 0, y: (26 - size.height) / 2, width: 48, height: size.height), withAttributes: attributes)
|
|
1302
|
+
}.withRenderingMode(.alwaysOriginal)
|
|
1303
|
+
}
|
|
1304
|
+
let appearance = tab.iconAppearance(selected: selected, motionAllowed: false)
|
|
1305
|
+
let filled = selected && tab.selectedSymbol == nil && !appearance.symbol.hasSuffix(".fill")
|
|
1306
|
+
? UIImage(systemName: appearance.symbol + ".fill") : nil
|
|
1307
|
+
let image = filled ?? UIImage(systemName: appearance.symbol)
|
|
1308
|
+
if let color = appearance.tint.flatMap({ UIColor(hex: $0) }) {
|
|
1309
|
+
return image?.withTintColor(color, renderingMode: .alwaysOriginal)
|
|
1310
|
+
}
|
|
1311
|
+
return image
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
|
1315
|
+
let ids = hybrid.tabIds
|
|
1316
|
+
guard let index = viewControllers?.firstIndex(where: { $0 === viewController }), ids.indices.contains(index),
|
|
1317
|
+
let tab = CraftChrome.shared.tabs.first(where: { $0.id == ids[index] }) else { return false }
|
|
1318
|
+
// Craft may wait for the shared web page. Its committed stack decides
|
|
1319
|
+
// selection, including reselect-to-root and interruption of a swipe.
|
|
1320
|
+
CraftChrome.shared.tap(tab)
|
|
1321
|
+
return false
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private func updateSystemAccessory(hidden: Bool) {
|
|
1325
|
+
let chrome = CraftChrome.shared
|
|
1326
|
+
let model = hidden ? nil : chrome.tabAccessory
|
|
1327
|
+
if let model {
|
|
1328
|
+
var configuration = UIButton.Configuration.plain()
|
|
1329
|
+
configuration.image = UIImage(systemName: model.symbol)
|
|
1330
|
+
configuration.title = model.displayTitle
|
|
1331
|
+
configuration.subtitle = model.badge
|
|
1332
|
+
configuration.imagePadding = 8
|
|
1333
|
+
accessoryButton.configuration = configuration
|
|
1334
|
+
accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
|
|
1335
|
+
accessoryButton.accessibilityValue = model.badge
|
|
1336
|
+
accessoryButton.accessibilityTraits = chrome.accessorySelected ? [.button, .selected] : [.button]
|
|
1337
|
+
}
|
|
1338
|
+
#if compiler(>=6.2)
|
|
1339
|
+
if #available(iOS 26.0, *) {
|
|
1340
|
+
if (model == nil) != (bottomAccessory == nil) {
|
|
1341
|
+
bottomAccessory = model.map { _ in UITabAccessory(contentView: accessoryButton) }
|
|
1342
|
+
}
|
|
1343
|
+
accessoryModel = model
|
|
1344
|
+
return
|
|
1345
|
+
}
|
|
1346
|
+
#endif
|
|
1347
|
+
// Older systems have no bottom-accessory container. Keep the action
|
|
1348
|
+
// available above the system bar, without recreating the tab control.
|
|
1349
|
+
if model != nil, accessoryButton.superview == nil { view.addSubview(accessoryButton) }
|
|
1350
|
+
accessoryButton.isHidden = model == nil
|
|
1351
|
+
accessoryButton.backgroundColor = config.resolvedBackgroundColor
|
|
1352
|
+
accessoryModel = model
|
|
1353
|
+
for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
|
|
1354
|
+
view.setNeedsLayout()
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
@objc private func openTabAccessory() {
|
|
1358
|
+
if let accessoryModel { hybrid.open(accessoryModel.id) }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
override func viewDidLayoutSubviews() {
|
|
1362
|
+
super.viewDidLayoutSubviews()
|
|
1363
|
+
guard hybrid.controller === self else { return }
|
|
1364
|
+
#if compiler(>=6.2)
|
|
1365
|
+
if #available(iOS 26.0, *) {
|
|
1366
|
+
// UIKit already supplies the native safe area. This measurement is
|
|
1367
|
+
// only the web CSS contract, not another native bottom inset.
|
|
1368
|
+
let top = bottomAccessory.map { $0.contentView.convert($0.contentView.bounds, to: view).minY } ?? tabBar.frame.minY
|
|
1369
|
+
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - top))
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
#endif
|
|
1373
|
+
accessoryButton.frame = CGRect(x: 16, y: tabBar.frame.minY - 44, width: max(0, view.bounds.width - 32), height: 44)
|
|
1374
|
+
CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + (accessoryModel == nil ? 0 : 44))
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1188
1377
|
private var webAttached = false
|
|
1189
1378
|
|
|
1190
1379
|
private func installTabSwipeGestures() {
|
|
@@ -1304,6 +1493,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1304
1493
|
self.reveal(slot)
|
|
1305
1494
|
}
|
|
1306
1495
|
}
|
|
1496
|
+
if let container = current.view.superview, target.view.superview !== container {
|
|
1497
|
+
container.addSubview(target.view)
|
|
1498
|
+
}
|
|
1307
1499
|
target.view.isHidden = false
|
|
1308
1500
|
let width = view.bounds.width
|
|
1309
1501
|
tabSwipeFrom = -direction * width
|
|
@@ -1321,7 +1513,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1321
1513
|
}
|
|
1322
1514
|
}
|
|
1323
1515
|
target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom + tabSwipeBase, dy: 0)
|
|
1324
|
-
view.bringSubviewToFront(target.view)
|
|
1516
|
+
target.view.superview?.bringSubviewToFront(target.view)
|
|
1325
1517
|
CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
|
|
1326
1518
|
lastNavigation = CACurrentMediaTime()
|
|
1327
1519
|
CraftFramePacing.shared.begin("tab swipe")
|
|
@@ -1416,10 +1608,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1416
1608
|
let completing = settleCompleting
|
|
1417
1609
|
CraftFramePacing.shared.end("tab swipe")
|
|
1418
1610
|
finish()
|
|
1611
|
+
// The two pages are exactly one viewport apart. UIKit can reparent
|
|
1612
|
+
// the outgoing layer when selection commits; its presentation frame
|
|
1613
|
+
// then uses the new container's coordinates, not the old swipe's.
|
|
1419
1614
|
let (stay, stayX, other, otherId, otherX) = completing
|
|
1420
|
-
? (target, targetX, current, outgoing,
|
|
1421
|
-
: (current, currentX, target, targetId,
|
|
1615
|
+
? (target, targetX, current, outgoing, targetX - tabSwipeFrom)
|
|
1616
|
+
: (current, currentX, target, targetId, currentX + tabSwipeFrom)
|
|
1422
1617
|
guard abs(stayX) > 0.5 else { return }
|
|
1618
|
+
// Selection removes UIKit's outgoing view. A caught interactive swipe
|
|
1619
|
+
// needs it beside the new selected view again, in the same container.
|
|
1620
|
+
if let container = stay.view.superview, other.view.superview !== container {
|
|
1621
|
+
container.addSubview(other.view)
|
|
1622
|
+
}
|
|
1423
1623
|
stay.view.frame = view.bounds.offsetBy(dx: stayX, dy: 0)
|
|
1424
1624
|
other.view.isHidden = false
|
|
1425
1625
|
other.view.frame = view.bounds.offsetBy(dx: otherX, dy: 0)
|
|
@@ -1604,17 +1804,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1604
1804
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1605
1805
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
1606
1806
|
nav.setNavigationBarHidden(true, animated: false)
|
|
1607
|
-
addChild(nav)
|
|
1608
1807
|
nav.view.frame = view.bounds
|
|
1609
1808
|
nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
1610
1809
|
nav.view.isHidden = hidden
|
|
1611
|
-
view.addSubview(nav.view)
|
|
1612
|
-
nav.didMove(toParent: self)
|
|
1613
1810
|
// Only once the stack's view has loaded: the recognizer is made then,
|
|
1614
1811
|
// and before it is nil, so the delegate never took.
|
|
1615
1812
|
for recognizer in popRecognizers(of: nav) { recognizer.delegate = self }
|
|
1616
1813
|
stacks[tab] = nav
|
|
1617
1814
|
shownStacks[ObjectIdentifier(nav)] = nav.viewControllers
|
|
1815
|
+
synchronizeSystemTabs()
|
|
1816
|
+
if hidden, nav.view.superview == nil, let container = visibleStack?.view.superview {
|
|
1817
|
+
container.addSubview(nav.view)
|
|
1818
|
+
}
|
|
1618
1819
|
return nav
|
|
1619
1820
|
}
|
|
1620
1821
|
|
|
@@ -1664,10 +1865,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1664
1865
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1665
1866
|
}
|
|
1666
1867
|
currentTab = id
|
|
1868
|
+
synchronizeSystemTabs()
|
|
1667
1869
|
if let top = nav.topViewController { CraftChrome.shared.visiblePath = nativePaths[ObjectIdentifier(top)] }
|
|
1668
1870
|
for (key, other) in stacks { other.view.isHidden = key != id }
|
|
1669
1871
|
nav.view.frame = view.bounds
|
|
1670
|
-
view.bringSubviewToFront(nav.view)
|
|
1872
|
+
nav.view.superview?.bringSubviewToFront(nav.view)
|
|
1671
1873
|
prepareAdjacentNativeTabs()
|
|
1672
1874
|
if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
|
|
1673
1875
|
adoptWeb(into: slot)
|
|
@@ -2028,15 +2230,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2028
2230
|
|
|
2029
2231
|
func tabBarCovers(_ height: CGFloat) {
|
|
2030
2232
|
tabBarHeight = height
|
|
2031
|
-
let inset = safeAreaForTabBar()
|
|
2032
|
-
for nav in stacks.values where nav.additionalSafeAreaInsets.bottom != inset {
|
|
2033
|
-
nav.additionalSafeAreaInsets.bottom = inset
|
|
2034
|
-
}
|
|
2035
2233
|
}
|
|
2036
2234
|
|
|
2037
2235
|
private func safeAreaForTabBar() -> CGFloat {
|
|
2038
|
-
|
|
2039
|
-
|
|
2236
|
+
// UIKit supplies the bar (and on iOS 26, its accessory) inset. Only
|
|
2237
|
+
// the older-system accessory above that bar needs an extra inset.
|
|
2238
|
+
#if compiler(>=6.2)
|
|
2239
|
+
if #available(iOS 26.0, *) { return 0 }
|
|
2240
|
+
#endif
|
|
2241
|
+
let chrome = CraftChrome.shared
|
|
2242
|
+
return chrome.tabBarVisible && !chrome.tabs.isEmpty && chrome.tabAccessory != nil ? 44 : 0
|
|
2040
2243
|
}
|
|
2041
2244
|
|
|
2042
2245
|
override func viewSafeAreaInsetsDidChange() {
|
|
@@ -2157,6 +2360,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2157
2360
|
|
|
2158
2361
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
2159
2362
|
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
2363
|
+
// System tab-bar tracking owns touches on the bar and accessory;
|
|
2364
|
+
// Craft's content swipe must not steal their drag/hold gestures.
|
|
2365
|
+
let point = gestureRecognizer.location(in: view)
|
|
2366
|
+
if !tabBar.isHidden, tabBar.frame.contains(point) { return false }
|
|
2367
|
+
if !accessoryButton.isHidden, accessoryButton.window != nil,
|
|
2368
|
+
accessoryButton.convert(accessoryButton.bounds, to: view).contains(point) { return false }
|
|
2160
2369
|
// A swipe may begin while the last one settles: it catches it.
|
|
2161
2370
|
guard activeTabSwipeTarget == nil || tabSwipeSettling,
|
|
2162
2371
|
hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
|
|
@@ -49,6 +49,7 @@ enum CraftNativeActions {
|
|
|
49
49
|
values.append("storageSetIfAbsent")
|
|
50
50
|
values.append("viewScroll")
|
|
51
51
|
values.append("crypto")
|
|
52
|
+
values.append("shortcuts")
|
|
52
53
|
if config.enableBiometric { values.append("biometric") }
|
|
53
54
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
54
55
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -212,6 +213,17 @@ enum CraftNativeActions {
|
|
|
212
213
|
complete(requestToken, storage(method: method, args: args), completion)
|
|
213
214
|
}
|
|
214
215
|
return
|
|
216
|
+
case ("Shortcuts", _):
|
|
217
|
+
// UIKit quick actions belong to the app, not the calling route.
|
|
218
|
+
// A queued call from a disposed screen must not change that menu.
|
|
219
|
+
DispatchQueue.main.async {
|
|
220
|
+
cancellationLock.lock()
|
|
221
|
+
let cancelled = cancelledRequests.remove(requestToken) != nil
|
|
222
|
+
cancellationLock.unlock()
|
|
223
|
+
guard !cancelled else { return }
|
|
224
|
+
complete(requestToken, shortcuts(method: method, args: args), completion)
|
|
225
|
+
}
|
|
226
|
+
return
|
|
215
227
|
case ("SecureStorage", _):
|
|
216
228
|
guard config.enableSecureStorage else {
|
|
217
229
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
|
|
@@ -950,6 +962,44 @@ enum CraftNativeActions {
|
|
|
950
962
|
}
|
|
951
963
|
}
|
|
952
964
|
|
|
965
|
+
private static func shortcuts(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
966
|
+
if method == "clear" {
|
|
967
|
+
guard args.isEmpty else { return failure("INVALID_ARGUMENT", "Shortcuts.clear takes no arguments") }
|
|
968
|
+
UIApplication.shared.shortcutItems = nil
|
|
969
|
+
return .success(true)
|
|
970
|
+
}
|
|
971
|
+
guard method == "set" else { return failure("UNSUPPORTED_METHOD", "Unknown Shortcuts method") }
|
|
972
|
+
guard args.count == 1, let items = args.first as? [[String: Any]], items.count <= 4 else {
|
|
973
|
+
return failure("INVALID_ARGUMENT", "Shortcuts.set needs a list of at most four shortcuts")
|
|
974
|
+
}
|
|
975
|
+
var types = Set<String>()
|
|
976
|
+
var shortcuts: [UIApplicationShortcutItem] = []
|
|
977
|
+
for item in items {
|
|
978
|
+
guard let type = item["type"] as? String, !type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
979
|
+
let title = item["title"] as? String, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
980
|
+
types.insert(type).inserted else {
|
|
981
|
+
return failure("INVALID_ARGUMENT", "Every shortcut needs a unique type and a non-empty title")
|
|
982
|
+
}
|
|
983
|
+
for name in ["subtitle", "iconName"] where item[name] != nil {
|
|
984
|
+
guard item[name] is String else { return failure("INVALID_ARGUMENT", "Shortcut \(name) must be a string") }
|
|
985
|
+
}
|
|
986
|
+
var userInfo: [String: NSSecureCoding]?
|
|
987
|
+
if let raw = item["userInfo"] {
|
|
988
|
+
guard let info = raw as? [String: Any], PropertyListSerialization.propertyList(info, isValidFor: .binary),
|
|
989
|
+
let secure = info as? [String: NSSecureCoding] else {
|
|
990
|
+
return failure("INVALID_ARGUMENT", "Shortcut userInfo must contain property-list values")
|
|
991
|
+
}
|
|
992
|
+
userInfo = secure
|
|
993
|
+
}
|
|
994
|
+
shortcuts.append(UIApplicationShortcutItem(type: type, localizedTitle: title,
|
|
995
|
+
localizedSubtitle: item["subtitle"] as? String,
|
|
996
|
+
icon: (item["iconName"] as? String).map { UIApplicationShortcutIcon(systemImageName: $0) }, userInfo: userInfo))
|
|
997
|
+
}
|
|
998
|
+
// Validate the whole list before replacing the previously valid menu.
|
|
999
|
+
UIApplication.shared.shortcutItems = shortcuts
|
|
1000
|
+
return .success(["count": shortcuts.count])
|
|
1001
|
+
}
|
|
1002
|
+
|
|
953
1003
|
private static func storage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
954
1004
|
do {
|
|
955
1005
|
if method == "clear" { try CraftPersistentStorage.clear(prefix: storagePrefix); return .success(true) }
|
|
@@ -192,10 +192,12 @@ enum CraftNativeFiles {
|
|
|
192
192
|
do {
|
|
193
193
|
let url = try newFile(named: name)
|
|
194
194
|
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
195
|
+
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
|
195
196
|
return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
|
|
196
197
|
}
|
|
197
198
|
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
|
|
198
199
|
guard CGImageDestinationFinalize(destination) else {
|
|
200
|
+
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
|
199
201
|
return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
|
|
200
202
|
}
|
|
201
203
|
return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
|
|
@@ -731,11 +733,11 @@ enum CraftNativeMedia {
|
|
|
731
733
|
lock.lock()
|
|
732
734
|
defer { lock.unlock() }
|
|
733
735
|
if let flight, inFlight[address]?.id != flight {
|
|
734
|
-
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
736
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
735
737
|
}
|
|
736
738
|
// A background transfer from before the cache was cleared (sign-out).
|
|
737
739
|
if let stillWanted, !stillWanted() {
|
|
738
|
-
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
740
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
739
741
|
}
|
|
740
742
|
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
741
743
|
defer { try? fm.removeItem(at: staging) }
|
|
@@ -784,7 +786,7 @@ enum CraftNativeMedia {
|
|
|
784
786
|
catch { failure = error }
|
|
785
787
|
}
|
|
786
788
|
lock.unlock()
|
|
787
|
-
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
789
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))) }
|
|
788
790
|
if let failure { throw failure }
|
|
789
791
|
return removed
|
|
790
792
|
}
|