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