craft-native 0.0.359 → 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/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/CraftNativeScreen.swift +34 -11
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.360";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -412,7 +412,9 @@ class CraftEventManager {
|
|
|
412
412
|
/// The app script's delivery, once its bundle has run.
|
|
413
413
|
private var appScriptSink: ((String, [String: Any]) -> Void)?
|
|
414
414
|
|
|
415
|
-
|
|
415
|
+
// Internal construction also allows isolated event-delivery tests without
|
|
416
|
+
// replacing the running app's singleton listener.
|
|
417
|
+
init() {}
|
|
416
418
|
|
|
417
419
|
func setWebView(_ webView: WKWebView) {
|
|
418
420
|
self.webView = webView
|
|
@@ -668,7 +670,6 @@ struct CraftApp: App {
|
|
|
668
670
|
CraftColorSchemeObserver()
|
|
669
671
|
CraftHybridRoot(config: appState.config)
|
|
670
672
|
.ignoresSafeArea()
|
|
671
|
-
CraftTabBarView()
|
|
672
673
|
CraftSplashView(background: appState.config.resolvedBackgroundColor)
|
|
673
674
|
}
|
|
674
675
|
// The keyboard covers the tab bar, as it does UIKit's: the
|
|
@@ -88,8 +88,13 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
private func startScan() {
|
|
91
|
-
guard scanCompletion != nil else { return }
|
|
92
|
-
|
|
91
|
+
guard scanCompletion != nil, let central else { return }
|
|
92
|
+
// A device connected by another app may have stopped advertising.
|
|
93
|
+
// This lookup does not change its system pairing or connection.
|
|
94
|
+
for found in central.retrieveConnectedPeripherals(withServices: scanServices) {
|
|
95
|
+
remember(found, name: found.name ?? "", rssi: nil)
|
|
96
|
+
}
|
|
97
|
+
central.scanForPeripherals(withServices: scanServices, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
private func finishScan() {
|
|
@@ -122,11 +127,18 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
125
|
-
guard scanCompletion != nil, devices.count < 64 else { return }
|
|
126
130
|
let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? ""
|
|
131
|
+
remember(peripheral, name: name, rssi: RSSI.intValue)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func remember(_ peripheral: CBPeripheral, name: String, rssi: Int?) {
|
|
135
|
+
guard scanCompletion != nil else { return }
|
|
127
136
|
guard namePrefix.isEmpty || name.lowercased().hasPrefix(namePrefix.lowercased()) else { return }
|
|
128
137
|
let id = peripheral.identifier.uuidString
|
|
129
|
-
devices[id]
|
|
138
|
+
guard devices[id] != nil || devices.count < 64 else { return }
|
|
139
|
+
devices[id] = peripheral
|
|
140
|
+
if !name.isEmpty || names[id] == nil { names[id] = name }
|
|
141
|
+
if let rssi { strengths[id] = rssi }
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
private func connect(_ options: [String: Any], _ completion: @escaping Completion) {
|
|
@@ -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) }
|
|
@@ -6742,34 +6742,35 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6742
6742
|
if (runtimeCallback) runtimeCallback(raw);
|
|
6743
6743
|
};
|
|
6744
6744
|
var nextRequest = 0;
|
|
6745
|
-
function request(method, args) {
|
|
6745
|
+
function request(module, method, args, untimed) {
|
|
6746
6746
|
return new Promise(function(resolve, reject) {
|
|
6747
|
-
var id = 'craft-
|
|
6747
|
+
var id = 'craft-host-' + (++nextRequest);
|
|
6748
6748
|
var remove = bridge.addMessageListener(function(raw) {
|
|
6749
6749
|
var message;
|
|
6750
6750
|
try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
|
|
6751
6751
|
var payload = message && message.payload;
|
|
6752
6752
|
if (!payload || payload.requestId !== id) return;
|
|
6753
|
+
if (message.type !== 'API_ERROR' && message.type !== 'API_RESPONSE') return;
|
|
6753
6754
|
remove();
|
|
6754
6755
|
if (message.type === 'API_ERROR') {
|
|
6755
|
-
var failure = new Error(payload.message || 'Native
|
|
6756
|
+
var failure = new Error(payload.message || 'Native capability failed');
|
|
6756
6757
|
failure.code = payload.code || 'NATIVE_CALL_FAILED';
|
|
6757
6758
|
reject(failure);
|
|
6758
6759
|
} else if (message.type === 'API_RESPONSE') {
|
|
6759
6760
|
resolve(payload.data === undefined ? null : payload.data);
|
|
6760
6761
|
}
|
|
6761
6762
|
});
|
|
6762
|
-
bridge.postMessage(JSON.stringify({
|
|
6763
|
+
try { bridge.postMessage(JSON.stringify({
|
|
6763
6764
|
type: 'API_REQUEST',
|
|
6764
6765
|
id: id,
|
|
6765
6766
|
payload: {
|
|
6766
6767
|
version: bridge.capabilityProtocolVersion || 1,
|
|
6767
|
-
module:
|
|
6768
|
+
module: module,
|
|
6768
6769
|
method: method,
|
|
6769
6770
|
args: args,
|
|
6770
|
-
untimed: true
|
|
6771
|
+
untimed: untimed === true
|
|
6771
6772
|
}
|
|
6772
|
-
}));
|
|
6773
|
+
})); } catch (error) { remove(); reject(error); }
|
|
6773
6774
|
});
|
|
6774
6775
|
}
|
|
6775
6776
|
// Native capability ownership wins over any browser-shaped
|
|
@@ -6780,11 +6781,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6780
6781
|
var craft = globalThis.craft = globalThis.craft || {};
|
|
6781
6782
|
if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
|
|
6782
6783
|
craft.dialog = {
|
|
6783
|
-
alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
|
|
6784
|
-
confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
|
|
6785
|
-
actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
|
|
6784
|
+
alert: function(options) { return request('Dialog', 'alert', [options || {}], true).then(function() {}); },
|
|
6785
|
+
confirm: function(options) { return request('Dialog', 'confirm', [options || {}], true).then(Boolean); },
|
|
6786
|
+
actionSheet: function(options) { return request('Dialog', 'actionSheet', [options || {}], true).then(function(id) { return typeof id === 'string' ? id : null; }); }
|
|
6786
6787
|
};
|
|
6787
6788
|
}
|
|
6789
|
+
if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('shortcuts') >= 0) {
|
|
6790
|
+
var shortcuts = {
|
|
6791
|
+
set: function(items) { return request('Shortcuts', 'set', [items]); },
|
|
6792
|
+
clear: function() { return request('Shortcuts', 'clear', []); }
|
|
6793
|
+
};
|
|
6794
|
+
// An older STX bundle may install a browser-only
|
|
6795
|
+
// namespace. Preserve extras, but keep set/clear native.
|
|
6796
|
+
Object.defineProperty(craft, 'shortcuts', {
|
|
6797
|
+
configurable: true, enumerable: true,
|
|
6798
|
+
get: function() { return shortcuts; },
|
|
6799
|
+
set: function(value) {
|
|
6800
|
+
if (!value || typeof value !== 'object') return;
|
|
6801
|
+
value.set = shortcuts.set;
|
|
6802
|
+
value.clear = shortcuts.clear;
|
|
6803
|
+
shortcuts = value;
|
|
6804
|
+
}
|
|
6805
|
+
});
|
|
6806
|
+
}
|
|
6788
6807
|
}
|
|
6789
6808
|
globalThis.__craftInstallNativeDialog = installNativeDialog;
|
|
6790
6809
|
installNativeDialog();
|
|
@@ -7438,7 +7457,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7438
7457
|
scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
|
|
7439
7458
|
guard scroll !== trackedScrollView else { return }
|
|
7440
7459
|
trackedScrollView = scroll
|
|
7441
|
-
|
|
7460
|
+
// The same primary vertical scroller drives the navigation bar and
|
|
7461
|
+
// the system tab bar's scroll-edge/minimization behavior.
|
|
7462
|
+
setContentScrollView(scroll, for: [.top, .bottom])
|
|
7442
7463
|
}
|
|
7443
7464
|
|
|
7444
7465
|
private func observeNativeEvents() {
|
|
@@ -8211,6 +8232,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8211
8232
|
private func clearRenderedTree() {
|
|
8212
8233
|
if let root = renderedRoot { detach(root, from: rootStack) }
|
|
8213
8234
|
renderedRoot = nil
|
|
8235
|
+
trackedScrollView = nil
|
|
8236
|
+
setContentScrollView(nil, for: [.top, .bottom])
|
|
8214
8237
|
rootIsSafeAreaView = false
|
|
8215
8238
|
rootStack.setViewportChild(nil, fillsHeight: false)
|
|
8216
8239
|
rootTopToSafeArea?.isActive = true
|