@craft-native/ios 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/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
@@ -174,7 +179,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
174
179
  `elevation` remains Android-specific. Gradient layers follow the native view's
175
180
  bounds and corners through layout changes.
176
181
 
177
- The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
182
+ The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. Dynamic `craft.shortcuts.set`/`clear` are not installed by the all-native host yet; configure static `appShortcuts` in `craft.config.json` to publish home-screen quick actions. Runtime shortcut registration remains tracked in #412. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
178
183
 
179
184
  `FlatList` rows are authored as keyed `:for` child rows in native STX; the
180
185
  maintained protocol does not serialize `data`/`renderItem` callbacks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.358",
3
+ "version": "0.0.360",
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) }
@@ -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 video cache was cleared"))
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 video cache was cleared"))
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 video cache was cleared"))) }
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
  }
@@ -1443,6 +1443,16 @@ enum CraftBasemap: String {
1443
1443
  static let outdoorCredit = "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
1444
1444
  }
1445
1445
 
1446
+ private final class CraftMarkerPhotoRedirects: NSObject, URLSessionTaskDelegate {
1447
+ static let session = URLSession(configuration: .ephemeral, delegate: CraftMarkerPhotoRedirects(), delegateQueue: nil)
1448
+
1449
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
1450
+ newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
1451
+ let url = request.url
1452
+ completionHandler(url?.scheme?.lowercased() == "https" && url?.user == nil && url?.password == nil ? request : nil)
1453
+ }
1454
+ }
1455
+
1446
1456
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1447
1457
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1448
1458
  private var overlaysByID: [String: MKPolyline] = [:]
@@ -1467,6 +1477,26 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1467
1477
  private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1468
1478
  private var markerBearings: [String: Double] = [:]
1469
1479
  private var markerPinColors: [String: UIColor] = [:]
1480
+ private struct MarkerPhoto: Equatable {
1481
+ let url: URL
1482
+ let size: CGFloat
1483
+ let circle: Bool
1484
+
1485
+ init?(_ props: [String: Any]) {
1486
+ guard let raw = props["image"] as? String, let url = URL(string: raw),
1487
+ url.scheme?.lowercased() == "https", let host = url.host, !host.isEmpty,
1488
+ url.user == nil, url.password == nil else { return nil }
1489
+ self.url = url
1490
+ // Current STX releases preserve unknown HTML/binding spellings.
1491
+ let value = CraftNativeMapView.numeric(props["imageSize"] ?? props["imagesize"] ?? props["image-size"]) ?? 30
1492
+ size = CGFloat(value.isFinite ? min(96, max(16, value)) : 30)
1493
+ circle = (props["imageShape"] ?? props["imageshape"] ?? props["image-shape"]) as? String != "square"
1494
+ }
1495
+ }
1496
+ private var markerPhotos: [String: MarkerPhoto] = [:]
1497
+ private var markerPhotoImages: [String: UIImage] = [:]
1498
+ private var markerPhotoTasks: [String: URLSessionDataTask] = [:]
1499
+ private var markerPhotoRequests: [String: UUID] = [:]
1470
1500
  private var tileOverlay: MKTileOverlay?
1471
1501
  private var tileTemplate: String?
1472
1502
  private var tileReplacesMap = true
@@ -1552,6 +1582,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1552
1582
  edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1553
1583
  var nextBearings: [String: Double] = [:]
1554
1584
  var nextPinColors: [String: UIColor] = [:]
1585
+ var nextPhotos: [String: MarkerPhoto] = [:]
1555
1586
  // Showing the user dot must not trigger a permission prompt from a
1556
1587
  // renderer pass. The app's existing location capability owns that
1557
1588
  // prompt; a map only opts in after authorization is already granted.
@@ -1580,6 +1611,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1580
1611
  guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
1581
1612
  let point = points.first else { continue }
1582
1613
  nextMarkers[id] = (point, overlay["title"] as? String)
1614
+ nextPhotos[id] = MarkerPhoto(overlay)
1583
1615
  if let color = overlay["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1584
1616
  if let bearing = Self.numeric(overlay["bearing"]), bearing.isFinite {
1585
1617
  nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
@@ -1598,6 +1630,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1598
1630
  if type == "mapmarker" || type == "marker" {
1599
1631
  guard let point = points.first else { continue }
1600
1632
  nextMarkers[id] = (point, childProps["title"] as? String)
1633
+ nextPhotos[id] = MarkerPhoto(childProps)
1601
1634
  if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1602
1635
  if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1603
1636
  nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
@@ -1626,6 +1659,12 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1626
1659
 
1627
1660
  markerBearings = nextBearings
1628
1661
  markerPinColors = nextPinColors
1662
+ for id in markerPhotos.keys where markerPhotos[id] != nextPhotos[id] {
1663
+ markerPhotoTasks.removeValue(forKey: id)?.cancel()
1664
+ markerPhotoRequests.removeValue(forKey: id)
1665
+ markerPhotoImages.removeValue(forKey: id)
1666
+ }
1667
+ markerPhotos = nextPhotos
1629
1668
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1630
1669
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1631
1670
  for (id, value) in nextMarkers {
@@ -1642,6 +1681,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1642
1681
  }
1643
1682
  }
1644
1683
  markerHandlers = nextHandlers
1684
+ for (id, photo) in markerPhotos where markerPhotoRequests[id] == nil {
1685
+ loadMarkerPhoto(photo, id: id)
1686
+ }
1645
1687
 
1646
1688
  let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
1647
1689
  for id in staleOverlays {
@@ -1773,6 +1815,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1773
1815
  markerHandlers.removeAll()
1774
1816
  markerBearings.removeAll()
1775
1817
  markerPinColors.removeAll()
1818
+ markerPhotoTasks.values.forEach { $0.cancel() }
1819
+ markerPhotoTasks.removeAll()
1820
+ markerPhotoRequests.removeAll()
1821
+ markerPhotoImages.removeAll()
1822
+ markerPhotos.removeAll()
1776
1823
  removeAnnotations(Array(annotationsByID.values))
1777
1824
  annotationsByID.removeAll()
1778
1825
  removeOverlays(Array(overlaysByID.values))
@@ -1890,7 +1937,15 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1890
1937
  }
1891
1938
 
1892
1939
  private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1893
- if let bearing = markerBearings[id] {
1940
+ if let photo = markerPhotoImages[id] {
1941
+ view.image = photo
1942
+ view.transform = .identity
1943
+ view.displayPriority = .required
1944
+ view.collisionMode = markerPhotos[id]?.circle == true ? .circle : .rectangle
1945
+ if #available(iOS 14.0, *) { view.zPriority = .max }
1946
+ view.isAccessibilityElement = true
1947
+ view.accessibilityTraits = .button
1948
+ } else if let bearing = markerBearings[id] {
1894
1949
  let color = (markerPinColors[id] ?? .systemGreen).resolvedColor(with: traitCollection)
1895
1950
  view.tintColor = color
1896
1951
  view.image = Self.markerImage(color: color, direction: true)
@@ -1919,6 +1974,45 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1919
1974
  }
1920
1975
  }
1921
1976
 
1977
+ private func loadMarkerPhoto(_ photo: MarkerPhoto, id: String) {
1978
+ let request = UUID()
1979
+ markerPhotoRequests[id] = request
1980
+ let scale = window?.screen.scale ?? UIScreen.main.scale
1981
+ let finish: (Data?) -> Void = { [weak self] data in
1982
+ let pixels = CGSize(width: photo.size * scale, height: photo.size * scale)
1983
+ let decoded = data.flatMap { $0.count <= 8 * 1024 * 1024 ? CraftNativeScreenController.decodeImage($0, fitting: (pixels, scale), fill: true) : nil }
1984
+ DispatchQueue.main.async { [weak self] in
1985
+ guard let self, self.markerPhotoRequests[id] == request else { return }
1986
+ self.markerPhotoTasks.removeValue(forKey: id)
1987
+ guard let decoded, decoded.size.width > 0, decoded.size.height > 0 else { return }
1988
+ let size = CGSize(width: photo.size, height: photo.size)
1989
+ let format = UIGraphicsImageRendererFormat()
1990
+ format.scale = scale
1991
+ self.markerPhotoImages[id] = UIGraphicsImageRenderer(size: size, format: format).image { _ in
1992
+ let bounds = CGRect(origin: .zero, size: size)
1993
+ if photo.circle { UIBezierPath(ovalIn: bounds).addClip() }
1994
+ let factor = max(photo.size / decoded.size.width, photo.size / decoded.size.height)
1995
+ let drawn = CGSize(width: decoded.size.width * factor, height: decoded.size.height * factor)
1996
+ decoded.draw(in: CGRect(x: (photo.size - drawn.width) / 2, y: (photo.size - drawn.height) / 2,
1997
+ width: drawn.width, height: drawn.height))
1998
+ }.withRenderingMode(.alwaysOriginal)
1999
+ if let annotation = self.annotationsByID[id], let view = self.view(for: annotation) {
2000
+ self.configureMarkerView(view, id: id)
2001
+ }
2002
+ }
2003
+ }
2004
+ if let local = CraftNativeMedia.local(photo.url.absoluteString) {
2005
+ DispatchQueue.global(qos: .userInitiated).async { finish(try? Data(contentsOf: local)) }
2006
+ } else {
2007
+ let task = CraftMarkerPhotoRedirects.session.dataTask(with: photo.url) { data, response, _ in
2008
+ let response = response as? HTTPURLResponse
2009
+ finish(response?.statusCode == 200 && response?.url?.scheme?.lowercased() == "https" ? data : nil)
2010
+ }
2011
+ markerPhotoTasks[id] = task
2012
+ task.resume()
2013
+ }
2014
+ }
2015
+
1922
2016
  private static let markerImages: NSCache<NSString, UIImage> = {
1923
2017
  let cache = NSCache<NSString, UIImage>()
1924
2018
  cache.countLimit = 64
@@ -4343,6 +4437,36 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
4343
4437
  }
4344
4438
  }
4345
4439
 
4440
+ /// A pressable wrapper must yield to an actionable descendant. Without this
4441
+ /// ordering UIKit may let the ancestor's tap recognizer win the same touch,
4442
+ /// so a nested Button never receives its physical action. A descendant
4443
+ /// control or tap recognizer is the source of the event; STX handles the
4444
+ /// logical bubbling from there.
4445
+ final class CraftPressGestureDelegate: NSObject, UIGestureRecognizerDelegate {
4446
+ static let shared = CraftPressGestureDelegate()
4447
+
4448
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4449
+ guard let parent = gestureRecognizer.view,
4450
+ let child = otherGestureRecognizer.view,
4451
+ child !== parent else { return false }
4452
+ return child.isDescendant(of: parent)
4453
+ }
4454
+
4455
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
4456
+ guard let container = gestureRecognizer.view else { return true }
4457
+ let hitView = container.hitTest(touch.location(in: container), with: nil)
4458
+ guard var candidate = hitView ?? touch.view else { return true }
4459
+ while candidate !== container {
4460
+ if candidate is UIControl { return false }
4461
+ if candidate.gestureRecognizers?.contains(where: { $0 !== gestureRecognizer && $0 is UITapGestureRecognizer }) == true {
4462
+ return false
4463
+ }
4464
+ candidate = candidate.superview ?? container
4465
+ }
4466
+ return true
4467
+ }
4468
+ }
4469
+
4346
4470
  /// The delegate of a Pressable's press-in/out feedback. That recognizer is
4347
4471
  /// a long press of no duration, so it begins the moment a finger lands; on its
4348
4472
  /// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -6538,15 +6662,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6538
6662
  let snapshotGet: @convention(block) (String) -> String? = { name in
6539
6663
  CraftSnapshots.read(name)
6540
6664
  }
6541
- let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
6542
- CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
6543
- CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6665
+ // JavaScriptCore retains these outer host blocks. A weak capture only
6666
+ // on the nested completion still makes the host block retain self,
6667
+ // forming controller -> JSContext -> host block -> controller.
6668
+ let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { [weak self] name, value, done in
6669
+ CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { [weak self] ok in
6670
+ CraftNativeScriptThread.run {
6671
+ // A writer can finish after UIKit has disposed this
6672
+ // screen. Resolve the pending promise as a cancellation,
6673
+ // never as a successful write into a dead runtime.
6674
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6675
+ }
6544
6676
  }
6545
6677
  }
6546
- let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
6678
+ let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { [weak self] keep, done in
6547
6679
  let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
6548
- CraftSnapshots.clear(keeping: Set(names)) { ok in
6549
- CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6680
+ CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
6681
+ CraftNativeScriptThread.run {
6682
+ done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6683
+ }
6550
6684
  }
6551
6685
  }
6552
6686
  let config = self.config
@@ -6608,34 +6742,35 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6608
6742
  if (runtimeCallback) runtimeCallback(raw);
6609
6743
  };
6610
6744
  var nextRequest = 0;
6611
- function request(method, args) {
6745
+ function request(module, method, args, untimed) {
6612
6746
  return new Promise(function(resolve, reject) {
6613
- var id = 'craft-dialog-' + (++nextRequest);
6747
+ var id = 'craft-host-' + (++nextRequest);
6614
6748
  var remove = bridge.addMessageListener(function(raw) {
6615
6749
  var message;
6616
6750
  try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
6617
6751
  var payload = message && message.payload;
6618
6752
  if (!payload || payload.requestId !== id) return;
6753
+ if (message.type !== 'API_ERROR' && message.type !== 'API_RESPONSE') return;
6619
6754
  remove();
6620
6755
  if (message.type === 'API_ERROR') {
6621
- var failure = new Error(payload.message || 'Native dialog failed');
6756
+ var failure = new Error(payload.message || 'Native capability failed');
6622
6757
  failure.code = payload.code || 'NATIVE_CALL_FAILED';
6623
6758
  reject(failure);
6624
6759
  } else if (message.type === 'API_RESPONSE') {
6625
6760
  resolve(payload.data === undefined ? null : payload.data);
6626
6761
  }
6627
6762
  });
6628
- bridge.postMessage(JSON.stringify({
6763
+ try { bridge.postMessage(JSON.stringify({
6629
6764
  type: 'API_REQUEST',
6630
6765
  id: id,
6631
6766
  payload: {
6632
6767
  version: bridge.capabilityProtocolVersion || 1,
6633
- module: 'Dialog',
6768
+ module: module,
6634
6769
  method: method,
6635
6770
  args: args,
6636
- untimed: true
6771
+ untimed: untimed === true
6637
6772
  }
6638
- }));
6773
+ })); } catch (error) { remove(); reject(error); }
6639
6774
  });
6640
6775
  }
6641
6776
  // Native capability ownership wins over any browser-shaped
@@ -6646,11 +6781,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6646
6781
  var craft = globalThis.craft = globalThis.craft || {};
6647
6782
  if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
6648
6783
  craft.dialog = {
6649
- alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
6650
- confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
6651
- 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; }); }
6652
6787
  };
6653
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
+ }
6654
6807
  }
6655
6808
  globalThis.__craftInstallNativeDialog = installNativeDialog;
6656
6809
  installNativeDialog();
@@ -7304,7 +7457,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7304
7457
  scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
7305
7458
  guard scroll !== trackedScrollView else { return }
7306
7459
  trackedScrollView = scroll
7307
- 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])
7308
7463
  }
7309
7464
 
7310
7465
  private func observeNativeEvents() {
@@ -7915,20 +8070,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7915
8070
  screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) }
7916
8071
  sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply)
7917
8072
  case ("Audio", "pause"):
7918
- sounds.pause(id: id)
7919
- reply(.success(true))
8073
+ reply(.success(sounds.pause(id: id)))
7920
8074
  case ("Audio", "resume"):
7921
- sounds.resume(id: id)
7922
- reply(.success(true))
8075
+ reply(.success(sounds.resume(id: id)))
7923
8076
  case ("Audio", "setVolume"):
7924
- sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)
7925
- reply(.success(true))
8077
+ reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
7926
8078
  case ("Audio", "stop"):
7927
- sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)
7928
- reply(.success(true))
8079
+ reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
7929
8080
  case ("Audio", "stopAll"):
7930
- sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)
7931
- reply(.success(true))
8081
+ reply(.success(sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)))
7932
8082
  default:
7933
8083
  return false
7934
8084
  }
@@ -8082,6 +8232,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8082
8232
  private func clearRenderedTree() {
8083
8233
  if let root = renderedRoot { detach(root, from: rootStack) }
8084
8234
  renderedRoot = nil
8235
+ trackedScrollView = nil
8236
+ setContentScrollView(nil, for: [.top, .bottom])
8085
8237
  rootIsSafeAreaView = false
8086
8238
  rootStack.setViewportChild(nil, fillsHeight: false)
8087
8239
  rootTopToSafeArea?.isActive = true
@@ -9339,6 +9491,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9339
9491
  let actionable = handler != nil || linkTargets[id] != nil
9340
9492
  if actionable, tapRecognizers[id] == nil {
9341
9493
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
9494
+ recognizer.cancelsTouchesInView = false
9495
+ recognizer.delegate = CraftPressGestureDelegate.shared
9342
9496
  tapRecognizers[id] = recognizer
9343
9497
  view.addGestureRecognizer(recognizer)
9344
9498
  } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
@@ -10111,6 +10265,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10111
10265
  // Decoded at the size it is drawn, when the style fixes that size.
10112
10266
  let pixels = Self.displayPixels(of: view)
10113
10267
  let fill = view.contentMode == .scaleAspectFill
10268
+ // media.keep owns private/offline copies keyed by the authored URL.
10269
+ // Read before the decoded/network cache, without retaining private
10270
+ // pixels there after the app clears its media on sign-out.
10271
+ if let local = CraftNativeMedia.local(uri) {
10272
+ DispatchQueue.global(qos: .userInitiated).async { [weak self, weak view] in
10273
+ let decoded = (try? Data(contentsOf: local)).flatMap { Self.decodeImage($0, fitting: pixels, fill: fill) }
10274
+ DispatchQueue.main.async { [weak self, weak view] in
10275
+ guard let self, let view, self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
10276
+ guard let image = decoded, CraftNativeMedia.local(uri) == local else {
10277
+ self.imageFailure(view, uri: uri, message: "Kept image is unavailable or invalid")
10278
+ return
10279
+ }
10280
+ view.image = self.imageForDisplay(image, view: view)
10281
+ self.imageDidChange(view)
10282
+ self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
10283
+ self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
10284
+ }
10285
+ }
10286
+ return
10287
+ }
10114
10288
  let cacheKey = pixels.map { "\(requestKey)@\(Int($0.size.width))x\(Int($0.size.height))\(fill ? "f" : "")" } ?? requestKey
10115
10289
  // Shown before: on screen in this same frame, already decoded.
10116
10290
  if let decoded = Self.decodedImages.object(forKey: cacheKey as NSString) {
@@ -10239,7 +10413,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10239
10413
  let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
10240
10414
  // UIPickerView exposes adjustable component wheels. Making the
10241
10415
  // picker itself a leaf hides those wheels and their selected values.
10242
- let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
10416
+ let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)
10243
10417
  || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
10244
10418
  || controlledVideo
10245
10419
  view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
@@ -279,6 +279,7 @@ final class CraftNativeSounds {
279
279
  /// The volume asked for; a fade in or out moves toward or away from it.
280
280
  var volume: Float
281
281
  var paused = false
282
+ var interrupted = false
282
283
 
283
284
  init(player: AVQueuePlayer, volume: Float) {
284
285
  self.player = player
@@ -306,9 +307,44 @@ final class CraftNativeSounds {
306
307
  private static var category: AVAudioSession.Category = .ambient
307
308
 
308
309
  private var sounds: [String: Sound] = [:]
310
+ private var interruptionObserver: NSObjectProtocol?
309
311
  /// `{ id, event: "ended" | "error", message? }` to the screen's script.
310
312
  var emit: (([String: Any]) -> Void)?
311
313
 
314
+ init() {
315
+ interruptionObserver = NotificationCenter.default.addObserver(
316
+ forName: AVAudioSession.interruptionNotification,
317
+ object: nil,
318
+ queue: .main,
319
+ ) { [weak self] note in
320
+ guard let self else { return }
321
+ let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
322
+ if type == AVAudioSession.InterruptionType.began.rawValue {
323
+ self.sounds.values.forEach { sound in
324
+ guard !sound.paused else { return }
325
+ sound.interrupted = true
326
+ sound.player.pause()
327
+ }
328
+ return
329
+ }
330
+ guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
331
+ let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
332
+ let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
333
+ self.sounds.values.forEach { sound in
334
+ guard sound.interrupted else { return }
335
+ sound.interrupted = false
336
+ // Without permission to auto-resume, keep a resumable paused
337
+ // handle. A later unrelated interruption must not restart it.
338
+ sound.paused = !shouldResume
339
+ if shouldResume { sound.player.play() }
340
+ }
341
+ }
342
+ }
343
+
344
+ deinit {
345
+ if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) }
346
+ }
347
+
312
348
  /// Starts `url`. `answer` hears once it plays (or could not be loaded).
313
349
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
314
350
  stop(id: id, fadeOut: 0)
@@ -362,40 +398,52 @@ final class CraftNativeSounds {
362
398
  if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
363
399
  }
364
400
 
365
- func pause(id: String) {
366
- guard let sound = sounds[id] else { return }
401
+ @discardableResult
402
+ func pause(id: String) -> Bool {
403
+ guard let sound = sounds[id] else { return false }
367
404
  sound.paused = true
405
+ sound.interrupted = false
368
406
  sound.fade?.invalidate()
369
407
  sound.player.pause()
408
+ return true
370
409
  }
371
410
 
372
- func resume(id: String) {
373
- guard let sound = sounds[id], sound.paused else { return }
411
+ @discardableResult
412
+ func resume(id: String) -> Bool {
413
+ guard let sound = sounds[id], sound.paused else { return false }
374
414
  sound.paused = false
415
+ sound.interrupted = false
375
416
  sound.player.volume = sound.volume
376
417
  sound.player.play()
418
+ return true
377
419
  }
378
420
 
379
421
  /// Changes the volume, over `fade` seconds when given.
380
- func setVolume(id: String, volume: Double, fade: Double) {
381
- guard let sound = sounds[id] else { return }
422
+ @discardableResult
423
+ func setVolume(id: String, volume: Double, fade: Double) -> Bool {
424
+ guard let sound = sounds[id] else { return false }
382
425
  sound.volume = Float(min(max(volume, 0), 1))
383
426
  if fade > 0, !sound.paused { ramp(sound, to: sound.volume, over: fade) }
384
427
  else { sound.fade?.invalidate(); sound.player.volume = sound.volume }
428
+ return true
385
429
  }
386
430
 
387
431
  /// Stops the sound, fading it out over `fadeOut` seconds first.
388
- func stop(id: String, fadeOut: Double) {
389
- guard let sound = sounds[id] else { return }
390
- guard fadeOut > 0, !sound.paused else { remove(id); return }
432
+ @discardableResult
433
+ func stop(id: String, fadeOut: Double) -> Bool {
434
+ guard let sound = sounds[id] else { return false }
435
+ guard fadeOut > 0, !sound.paused else { remove(id); return true }
391
436
  ramp(sound, to: 0, over: fadeOut) { [weak self, weak sound] in
392
437
  guard let self, let sound, self.sounds[id] === sound else { return }
393
438
  self.remove(id)
394
439
  }
440
+ return true
395
441
  }
396
442
 
397
- func stopAll(fadeOut: Double) {
443
+ @discardableResult
444
+ func stopAll(fadeOut: Double) -> Bool {
398
445
  for id in Array(sounds.keys) { stop(id: id, fadeOut: fadeOut) }
446
+ return true
399
447
  }
400
448
 
401
449
  /// The screen closed: everything stops at once.