@craft-native/ios 0.0.359 → 0.0.361

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  Build native iOS apps with web technologies using Craft.
4
4
 
5
+ Bluetooth GATT discovery includes iOS-connected peripherals matching the requested
6
+ services as well as new advertisements. A watch connected through another app can
7
+ therefore appear without changing its system pairing. Discovery still requires
8
+ Bluetooth permission and does not prove that the device accepts the app's protocol.
9
+
5
10
  ## Native celebration
6
11
 
7
12
  Any native `View` may opt into a finite card entrance, light sweep and confetti
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.359",
3
+ "version": "0.0.361",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- private init() {}
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
- central?.scanForPeripherals(withServices: scanServices, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
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] = peripheral; names[id] = name; strengths[id] = RSSI.intValue
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: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
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.addSubview(parking)
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, currentX)
1421
- : (current, currentX, target, targetId, targetX)
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
- let bottom = view.window?.safeAreaInsets.bottom ?? view.safeAreaInsets.bottom
2039
- return max(0, tabBarHeight - bottom)
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-dialog-' + (++nextRequest);
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 dialog failed');
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: 'Dialog',
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
- setContentScrollView(scroll, for: .top)
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