@craft-native/ios 0.0.254 → 0.0.272
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 +17 -6
- package/package.json +1 -1
- package/templates/CraftApp.swift +55 -7
- package/templates/CraftHealth.swift +38 -0
- package/templates/CraftHybrid.swift +50 -7
- package/templates/CraftNativeActions.swift +109 -22
- package/templates/CraftNativeFlatList.swift +187 -18
- package/templates/CraftNativeMutation.swift +3 -1
- package/templates/CraftNativeScreen.swift +466 -24
package/README.md
CHANGED
|
@@ -77,11 +77,19 @@ decoration. Resuming does not replay it. Available on both native renderers.
|
|
|
77
77
|
The iOS support is built into the main `craft` CLI:
|
|
78
78
|
|
|
79
79
|
`Pressable`, `TouchableOpacity`, and `TouchableHighlight` render as native
|
|
80
|
-
pressable flow containers. `Modal` renders a parent-scoped
|
|
81
|
-
`transparent`, `
|
|
82
|
-
|
|
80
|
+
pressable flow containers. `Modal` renders a parent-scoped native layer in native mode, with `visible`,
|
|
81
|
+
`transparent`, `nonModal`, `dismissOnOutsideTap`, `animationType`, `onShow`,
|
|
82
|
+
`onDismiss`, and `onRequestClose` support. Non-modal popups remain pass-through
|
|
83
|
+
unless outside-tap dismissal is explicitly enabled. `FlatList` item roots receive the same `onLayout` callback as ordinary native
|
|
83
84
|
views when rows are materialized or recycled.
|
|
84
85
|
|
|
86
|
+
`View.measure` and non-modal `left`/`top` placement use the same `screenViewport`
|
|
87
|
+
coordinate space. Safe-area and navigation-bar offsets are not added implicitly;
|
|
88
|
+
anchored callers must clamp against the measured viewport when needed. Re-run
|
|
89
|
+
measurement after the anchor or screen root emits `onLayout`, and after every
|
|
90
|
+
`onScroll` from an ancestor; those are the host-neutral invalidation signals for
|
|
91
|
+
repositioning an anchored popup.
|
|
92
|
+
|
|
85
93
|
```bash
|
|
86
94
|
bun add craft-native
|
|
87
95
|
```
|
|
@@ -151,6 +159,9 @@ bounds and corners through layout changes.
|
|
|
151
159
|
|
|
152
160
|
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`, `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).
|
|
153
161
|
|
|
162
|
+
`FlatList` rows are authored as keyed `:for` child rows in native STX; the
|
|
163
|
+
maintained protocol does not serialize `data`/`renderItem` callbacks.
|
|
164
|
+
|
|
154
165
|
JavaScriptCore has no DOM `window`; Craft aliases `globalThis.window` to
|
|
155
166
|
`globalThis` before installing the native bridge so browser-safe SDK calls such
|
|
156
167
|
as `window.craft.dialog.actionSheet()` reach the native capability host. This
|
|
@@ -220,9 +231,9 @@ Follow it with `selected` and content checks, then a full `swipe` to verify the
|
|
|
220
231
|
next gesture still works. Synchronized XCTest gestures do not replace separate
|
|
221
232
|
direct-input rapid-tap checks.
|
|
222
233
|
|
|
223
|
-
The iOS host uses the shared tree-layout ABI for eligible definite-size
|
|
224
|
-
|
|
225
|
-
runtimes or unsupported styles. Both paths implement wrapped-line and grid
|
|
234
|
+
The iOS host uses the shared tree-layout ABI for eligible definite-size
|
|
235
|
+
containers, including reverse and wrap-reverse direct flows, and retains a
|
|
236
|
+
local fallback for older runtimes or unsupported styles. Both paths implement wrapped-line and grid
|
|
226
237
|
`alignContent`, including `space-evenly`, so a missing optional shared symbol
|
|
227
238
|
does not change placement semantics.
|
|
228
239
|
Reverse flex directions also retain wrapped line order on the cross axis while
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -686,6 +686,9 @@ final class CraftChrome: ObservableObject {
|
|
|
686
686
|
/// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
|
|
687
687
|
/// Native screens use their own per-screen `tabSwipeEnabled` option.
|
|
688
688
|
private(set) var tabSwipeEnabled = true
|
|
689
|
+
/// The selected tab highlight's corner radius. Nil preserves the default
|
|
690
|
+
/// capsule, while a finite non-negative value opts into a rounded rect.
|
|
691
|
+
private(set) var tabSelectionCornerRadius: CGFloat?
|
|
689
692
|
|
|
690
693
|
weak var webView: WKWebView?
|
|
691
694
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
@@ -794,10 +797,21 @@ final class CraftChrome: ObservableObject {
|
|
|
794
797
|
tabAccessory = next
|
|
795
798
|
}
|
|
796
799
|
|
|
797
|
-
/// A
|
|
798
|
-
|
|
800
|
+
/// A screen going away gives its accessory up without taking it off the
|
|
801
|
+
/// bar (a retained or prepared screen cannot clear another's): the screen coming in shows it until it says what it wants. Taken
|
|
802
|
+
/// off at once, the pill stretched across the bar and back while the
|
|
803
|
+
/// next screen (Messages, opened from the accessory; a tab opened for the
|
|
804
|
+
/// first time) started up and set the same accessory again.
|
|
805
|
+
func releaseTabAccessory(ownedBy owner: AnyObject) {
|
|
799
806
|
guard tabAccessoryOwner === owner else { return }
|
|
800
|
-
|
|
807
|
+
tabAccessoryOwner = nil
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/// Takes off an accessory nobody claims any more: the screen now showing
|
|
811
|
+
/// drew without asking for one, or is a page that never does.
|
|
812
|
+
func clearUnclaimedTabAccessory() {
|
|
813
|
+
guard tabAccessoryOwner == nil, tabAccessory != nil else { return }
|
|
814
|
+
tabAccessory = nil
|
|
801
815
|
}
|
|
802
816
|
|
|
803
817
|
func setHiddenByScreen(_ hidden: Bool) {
|
|
@@ -823,6 +837,7 @@ final class CraftChrome: ObservableObject {
|
|
|
823
837
|
switch body["type"] as? String {
|
|
824
838
|
case "tabBar":
|
|
825
839
|
pendingHide?.cancel()
|
|
840
|
+
tabSelectionCornerRadius = Self.selectionCornerRadius(body["selection"])
|
|
826
841
|
tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
|
|
827
842
|
guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
|
|
828
843
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
@@ -872,6 +887,15 @@ final class CraftChrome: ObservableObject {
|
|
|
872
887
|
}
|
|
873
888
|
}
|
|
874
889
|
|
|
890
|
+
private static func selectionCornerRadius(_ value: Any?) -> CGFloat? {
|
|
891
|
+
guard let selection = value as? [String: Any],
|
|
892
|
+
let raw = selection["cornerRadius"] as? NSNumber,
|
|
893
|
+
raw.doubleValue.isFinite,
|
|
894
|
+
raw.doubleValue >= 0,
|
|
895
|
+
raw.doubleValue <= Double(CGFloat.greatestFiniteMagnitude) else { return nil }
|
|
896
|
+
return CGFloat(raw.doubleValue)
|
|
897
|
+
}
|
|
898
|
+
|
|
875
899
|
/// A tap on a tab: the page navigates, and says which tab is current.
|
|
876
900
|
///
|
|
877
901
|
/// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
|
|
@@ -1175,7 +1199,7 @@ struct CraftTabBarView: View {
|
|
|
1175
1199
|
}
|
|
1176
1200
|
.foregroundStyle(isSelected ? selectedColor : Color.primary)
|
|
1177
1201
|
.frame(maxWidth: .infinity, minHeight: 54)
|
|
1178
|
-
.contentShape(
|
|
1202
|
+
.contentShape(CraftTabSelectionShape(cornerRadius: chrome.tabSelectionCornerRadius))
|
|
1179
1203
|
}
|
|
1180
1204
|
.buttonStyle(.plain)
|
|
1181
1205
|
.accessibilityShowsLargeContentViewer {
|
|
@@ -1188,7 +1212,11 @@ struct CraftTabBarView: View {
|
|
|
1188
1212
|
}
|
|
1189
1213
|
}
|
|
1190
1214
|
.background {
|
|
1191
|
-
CraftTabSelectionIndicator(
|
|
1215
|
+
CraftTabSelectionIndicator(
|
|
1216
|
+
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
|
|
1217
|
+
count: chrome.tabs.count,
|
|
1218
|
+
cornerRadius: chrome.tabSelectionCornerRadius
|
|
1219
|
+
)
|
|
1192
1220
|
}
|
|
1193
1221
|
}
|
|
1194
1222
|
|
|
@@ -1230,18 +1258,33 @@ struct CraftTabBarView: View {
|
|
|
1230
1258
|
}
|
|
1231
1259
|
}
|
|
1232
1260
|
|
|
1261
|
+
private struct CraftTabSelectionShape: Shape {
|
|
1262
|
+
let cornerRadius: CGFloat?
|
|
1263
|
+
|
|
1264
|
+
func path(in rect: CGRect) -> Path {
|
|
1265
|
+
if let cornerRadius {
|
|
1266
|
+
return RoundedRectangle(
|
|
1267
|
+
cornerRadius: min(cornerRadius, min(rect.width, rect.height) / 2),
|
|
1268
|
+
style: .continuous
|
|
1269
|
+
).path(in: rect)
|
|
1270
|
+
}
|
|
1271
|
+
return Capsule().path(in: rect)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1233
1275
|
private struct CraftTabSelectionIndicator: View {
|
|
1234
1276
|
@ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
|
|
1235
1277
|
let selected: Int?
|
|
1236
1278
|
let count: Int
|
|
1279
|
+
let cornerRadius: CGFloat?
|
|
1237
1280
|
|
|
1238
1281
|
var body: some View {
|
|
1239
1282
|
GeometryReader { geometry in
|
|
1240
1283
|
if let position = progress.position ?? selected.map({ CGFloat($0) }) {
|
|
1241
1284
|
let width = geometry.size.width / CGFloat(max(1, count))
|
|
1242
|
-
|
|
1243
|
-
.fill(Color.primary.opacity(0.09))
|
|
1285
|
+
CraftTabSelectionShape(cornerRadius: cornerRadius)
|
|
1244
1286
|
.frame(width: width, height: geometry.size.height)
|
|
1287
|
+
.foregroundStyle(Color.primary.opacity(0.09))
|
|
1245
1288
|
.offset(x: position * width)
|
|
1246
1289
|
}
|
|
1247
1290
|
}
|
|
@@ -6315,6 +6358,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6315
6358
|
|
|
6316
6359
|
SecItemDelete(query as CFDictionary)
|
|
6317
6360
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
6361
|
+
CraftKeychain.forget(account: key)
|
|
6318
6362
|
return status == errSecSuccess
|
|
6319
6363
|
}
|
|
6320
6364
|
|
|
@@ -6341,12 +6385,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6341
6385
|
kSecAttrAccount as String: key
|
|
6342
6386
|
]
|
|
6343
6387
|
let status = SecItemDelete(query as CFDictionary)
|
|
6388
|
+
CraftKeychain.forget(account: key)
|
|
6344
6389
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
6345
6390
|
}
|
|
6346
6391
|
|
|
6347
6392
|
private func secureClear() -> Bool {
|
|
6348
6393
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
|
|
6349
6394
|
let status = SecItemDelete(query as CFDictionary)
|
|
6395
|
+
CraftKeychain.forget(account: nil)
|
|
6350
6396
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
6351
6397
|
}
|
|
6352
6398
|
|
|
@@ -8227,6 +8273,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8227
8273
|
SecItemDelete(query as CFDictionary)
|
|
8228
8274
|
|
|
8229
8275
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
8276
|
+
CraftKeychain.forget(account: key)
|
|
8230
8277
|
if status == errSecSuccess {
|
|
8231
8278
|
resolveCallback(callbackId, result: ["success": true, "key": key])
|
|
8232
8279
|
} else {
|
|
@@ -8269,6 +8316,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8269
8316
|
}
|
|
8270
8317
|
|
|
8271
8318
|
let status = SecItemDelete(query as CFDictionary)
|
|
8319
|
+
CraftKeychain.forget(account: key)
|
|
8272
8320
|
if status == errSecSuccess || status == errSecItemNotFound {
|
|
8273
8321
|
resolveCallback(callbackId, result: ["success": true, "key": key])
|
|
8274
8322
|
} else {
|
|
@@ -43,6 +43,19 @@ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelega
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
@available(iOS 26.0, *)
|
|
47
|
+
private final class CraftHealthWorkoutBuilderDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
|
|
48
|
+
weak var owner: CraftHealth?
|
|
49
|
+
|
|
50
|
+
init(owner: CraftHealth) { self.owner = owner }
|
|
51
|
+
|
|
52
|
+
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
|
53
|
+
owner?.emitLiveWorkoutMetrics(workoutBuilder)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
/// Apple Health for the whole app: the web page's bridge (`craft.health` in
|
|
47
60
|
/// the hybrid's web view, CraftApp.swift) and native screens alike
|
|
48
61
|
/// (`Health` through CraftNativeActions), so a screen drawn natively reads
|
|
@@ -837,12 +850,15 @@ final class CraftHealth {
|
|
|
837
850
|
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
|
838
851
|
let builder = session.associatedWorkoutBuilder()
|
|
839
852
|
let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
|
|
853
|
+
let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
|
|
840
854
|
session.delegate = delegate
|
|
855
|
+
builder.delegate = metricsDelegate
|
|
841
856
|
builder.shouldCollectWorkoutEvents = true
|
|
842
857
|
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
|
843
858
|
liveWorkoutSession = session
|
|
844
859
|
liveWorkoutBuilder = builder
|
|
845
860
|
liveWorkoutDelegate = delegate
|
|
861
|
+
liveWorkoutBuilderDelegate = metricsDelegate
|
|
846
862
|
liveWorkoutID = UUID().uuidString.lowercased()
|
|
847
863
|
liveWorkoutType = type
|
|
848
864
|
liveWorkoutStart = Date()
|
|
@@ -923,11 +939,32 @@ final class CraftHealth {
|
|
|
923
939
|
liveWorkoutSession = nil
|
|
924
940
|
liveWorkoutBuilder = nil
|
|
925
941
|
liveWorkoutDelegate = nil
|
|
942
|
+
liveWorkoutBuilderDelegate = nil
|
|
926
943
|
liveWorkoutID = nil
|
|
927
944
|
liveWorkoutType = nil
|
|
928
945
|
liveWorkoutStart = nil
|
|
929
946
|
}
|
|
930
947
|
|
|
948
|
+
@available(iOS 26.0, *)
|
|
949
|
+
fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
|
|
950
|
+
guard let id = liveWorkoutID else { return }
|
|
951
|
+
var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
|
|
952
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
953
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
954
|
+
let value = builder.statistics(for: type)?.mostRecentQuantity()?.doubleValue(for: bpm), value.isFinite {
|
|
955
|
+
detail["heartRateBpm"] = value
|
|
956
|
+
}
|
|
957
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
958
|
+
let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .kilocalorie()), value.isFinite {
|
|
959
|
+
detail["activeEnergyCalories"] = value
|
|
960
|
+
}
|
|
961
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
962
|
+
let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
|
|
963
|
+
detail["distanceMeters"] = value
|
|
964
|
+
}
|
|
965
|
+
onMain { [weak self] in self?.sendWorkout(detail) }
|
|
966
|
+
}
|
|
967
|
+
|
|
931
968
|
private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
|
|
932
969
|
switch type {
|
|
933
970
|
case "running": return .running
|
|
@@ -965,6 +1002,7 @@ final class CraftHealth {
|
|
|
965
1002
|
private var liveWorkoutSession: AnyObject?
|
|
966
1003
|
private var liveWorkoutBuilder: AnyObject?
|
|
967
1004
|
private var liveWorkoutDelegate: AnyObject?
|
|
1005
|
+
private var liveWorkoutBuilderDelegate: AnyObject?
|
|
968
1006
|
private var liveWorkoutID: String?
|
|
969
1007
|
private var liveWorkoutType: String?
|
|
970
1008
|
private var liveWorkoutStart: Date?
|
|
@@ -240,8 +240,10 @@ enum CraftHybridKeychain {
|
|
|
240
240
|
static let nativeService = "dev.craft.native.secure"
|
|
241
241
|
|
|
242
242
|
static func set(_ key: String, value: String?) {
|
|
243
|
+
defer { CraftKeychain.forget(account: key) }
|
|
243
244
|
// Every item for the account, whatever its service.
|
|
244
245
|
SecItemDelete([kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key] as CFDictionary)
|
|
246
|
+
CraftKeychain.forget(account: key)
|
|
245
247
|
guard let value else { return }
|
|
246
248
|
for service in [nil, nativeService] {
|
|
247
249
|
var add: [String: Any] = [
|
|
@@ -1076,6 +1078,18 @@ final class CraftHybridWebSlot: UIViewController {
|
|
|
1076
1078
|
}
|
|
1077
1079
|
}
|
|
1078
1080
|
|
|
1081
|
+
/// A native stack lets the top screen consume Back for its presented modal
|
|
1082
|
+
/// before UIKit removes the screen from the navigation stack.
|
|
1083
|
+
final class CraftNativeNavigationController: UINavigationController {
|
|
1084
|
+
override func popViewController(animated: Bool) -> UIViewController? {
|
|
1085
|
+
if let screen = topViewController as? CraftNativeScreenController,
|
|
1086
|
+
screen.handleBackForModal() {
|
|
1087
|
+
return nil
|
|
1088
|
+
}
|
|
1089
|
+
return super.popViewController(animated: animated)
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1079
1093
|
final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
|
|
1080
1094
|
private let config: CraftConfig
|
|
1081
1095
|
private let hybrid = CraftHybrid.shared
|
|
@@ -1175,6 +1189,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1175
1189
|
tabSwipeRecognizers.append(recognizer)
|
|
1176
1190
|
}
|
|
1177
1191
|
|
|
1192
|
+
private func shouldCompleteTabSwipe(progress: CGFloat, speed: CGFloat) -> Bool {
|
|
1193
|
+
speed > -700 && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1178
1196
|
@objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
1179
1197
|
// A finger that lands while the last swipe settles takes the screens
|
|
1180
1198
|
// from where they are, as a paging scroll view does. This swipe used
|
|
@@ -1186,7 +1204,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1186
1204
|
let translation = recognizer.translation(in: view).x
|
|
1187
1205
|
let velocity = recognizer.velocity(in: view).x
|
|
1188
1206
|
switch recognizer.state {
|
|
1189
|
-
case .changed:
|
|
1207
|
+
case .began, .changed:
|
|
1208
|
+
// Catching a settle can begin at zero translation. Keep its
|
|
1209
|
+
// committed selection until the new finger actually moves.
|
|
1210
|
+
if recognizer.state == .began, caughtSettle != nil, abs(translation) <= 1 { return }
|
|
1190
1211
|
if activeTabSwipeTarget == nil {
|
|
1191
1212
|
let direction = abs(translation) > 1 ? translation : velocity
|
|
1192
1213
|
guard abs(direction) > 1 else { return }
|
|
@@ -1201,9 +1222,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1201
1222
|
// the caught screens finish where they were going.
|
|
1202
1223
|
if caughtSettle != nil { settleCaughtScreens(); return }
|
|
1203
1224
|
guard recognizer.state == .ended else { return }
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1225
|
+
// UIKit can coalesce a short gesture without a changed
|
|
1226
|
+
// callback. Keep the same completion rule as the animated
|
|
1227
|
+
// path instead of treating any 32-point drag as a full page.
|
|
1228
|
+
let direction = abs(translation) > 1 ? translation : velocity
|
|
1229
|
+
guard abs(direction) > 1 else { return }
|
|
1230
|
+
let sign: CGFloat = direction < 0 ? -1 : 1
|
|
1231
|
+
guard shouldCompleteTabSwipe(progress: translation * sign, speed: velocity * sign) else { return }
|
|
1232
|
+
let next = direction < 0 ? index + 1 : index - 1
|
|
1207
1233
|
guard tabs.indices.contains(next) else { return }
|
|
1208
1234
|
CraftChrome.shared.swipe(to: tabs[next])
|
|
1209
1235
|
return
|
|
@@ -1216,8 +1242,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1216
1242
|
let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
|
|
1217
1243
|
let progress = (translation + tabSwipeBase) * direction
|
|
1218
1244
|
let speed = velocity * direction
|
|
1219
|
-
let complete = recognizer.state == .ended && speed
|
|
1220
|
-
&& (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
1245
|
+
let complete = recognizer.state == .ended && shouldCompleteTabSwipe(progress: progress, speed: speed)
|
|
1221
1246
|
finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
|
|
1222
1247
|
CraftFramePacing.shared.end("tab swipe")
|
|
1223
1248
|
self?.activeTabSwipeTarget = nil
|
|
@@ -1566,7 +1591,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1566
1591
|
// Constructing the navigation controller can load the root's view and
|
|
1567
1592
|
// mount its script immediately. Set its initial focus before that.
|
|
1568
1593
|
if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
|
|
1569
|
-
let nav =
|
|
1594
|
+
let nav = CraftNativeNavigationController(rootViewController: root)
|
|
1570
1595
|
nav.delegate = self
|
|
1571
1596
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1572
1597
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -2011,6 +2036,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2011
2036
|
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
|
2012
2037
|
lastNavigation = CACurrentMediaTime()
|
|
2013
2038
|
CraftFramePacing.shared.end("navigation")
|
|
2039
|
+
// A page shows no native accessory; one a screen gave up is taken off.
|
|
2040
|
+
if viewController is CraftHybridWebSlot { CraftChrome.shared.clearUnclaimedTabAccessory() }
|
|
2014
2041
|
let key = ObjectIdentifier(navigationController)
|
|
2015
2042
|
let before = shownStacks[key] ?? []
|
|
2016
2043
|
let now = navigationController.viewControllers
|
|
@@ -2278,6 +2305,22 @@ final class CraftFramePacing: NSObject {
|
|
|
2278
2305
|
return result
|
|
2279
2306
|
}
|
|
2280
2307
|
|
|
2308
|
+
/// One piece of the screens' JavaScript (CraftNativeScriptThread): how
|
|
2309
|
+
/// long it waited behind other screens' work, and how long it ran. The
|
|
2310
|
+
/// screens share one serial script thread, so a screen in front can sit
|
|
2311
|
+
/// behind hidden tabs reloading; `waited` is where that shows.
|
|
2312
|
+
func scriptWork(_ name: String, waited: CFTimeInterval, ran: CFTimeInterval) {
|
|
2313
|
+
guard enabled else { return }
|
|
2314
|
+
write(String(format: "script %@ waited=%.1fms ran=%.1fms", name, waited * 1000, ran * 1000))
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/// A capability a screen asked for (a request, the keychain), from the
|
|
2318
|
+
/// ask to the answer. Logged from 4ms, as script work is.
|
|
2319
|
+
func capability(_ name: String, took: CFTimeInterval) {
|
|
2320
|
+
guard enabled, took >= 0.004 else { return }
|
|
2321
|
+
write(String(format: "capability %@ took=%.1fms", name, took * 1000))
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2281
2324
|
/// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
|
|
2282
2325
|
/// in the app's container, which a test run reads back whole (a
|
|
2283
2326
|
/// simulator's unified log can drop an app's lines under load). Written
|
|
@@ -42,7 +42,7 @@ enum CraftNativeActions {
|
|
|
42
42
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
43
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
44
44
|
// as it arrives) need nothing the app has to declare.
|
|
45
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
|
|
45
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
|
|
46
46
|
values.append("storageCompareAndSet")
|
|
47
47
|
values.append("storageSetIfAbsent")
|
|
48
48
|
values.append("viewScroll")
|
|
@@ -219,11 +219,13 @@ enum CraftNativeActions {
|
|
|
219
219
|
}
|
|
220
220
|
// A write that moves the sign-in token is the account's change:
|
|
221
221
|
// every screen hears it (CraftSession). Reads skip the check.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
222
|
+
// Off the main thread, in order (CraftKeychain.queue).
|
|
223
|
+
let isWrite = ["set", "remove", "delete", "clear"].contains(method)
|
|
224
|
+
CraftKeychain.queue.async {
|
|
225
|
+
let result = isWrite ? CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write) : write()
|
|
226
|
+
complete(requestToken, result, completion)
|
|
226
227
|
}
|
|
228
|
+
return
|
|
227
229
|
case ("Biometrics", "isAvailable"):
|
|
228
230
|
sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
|
|
229
231
|
case ("Biometrics", "getBiometricType"):
|
|
@@ -957,6 +959,7 @@ enum CraftNativeActions {
|
|
|
957
959
|
if method == "clear" {
|
|
958
960
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]
|
|
959
961
|
let status = SecItemDelete(query as CFDictionary)
|
|
962
|
+
CraftKeychain.forget(account: nil)
|
|
960
963
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
961
964
|
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
|
|
962
965
|
}
|
|
@@ -977,24 +980,38 @@ enum CraftNativeActions {
|
|
|
977
980
|
query[kSecValueData as String] = Data(value.utf8)
|
|
978
981
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
979
982
|
let removed = SecItemDelete(base as CFDictionary)
|
|
983
|
+
CraftKeychain.forget(account: key)
|
|
980
984
|
guard removed == errSecSuccess || removed == errSecItemNotFound else {
|
|
981
985
|
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be replaced (\(removed))")
|
|
982
986
|
}
|
|
983
987
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
988
|
+
CraftKeychain.forget(account: key)
|
|
984
989
|
return status == errSecSuccess ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be written (\(status))")
|
|
985
990
|
case "get":
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
991
|
+
// A failed read is not remembered: it is answered as a failure,
|
|
992
|
+
// and the next read asks the Keychain again.
|
|
993
|
+
var failed = false
|
|
994
|
+
let value = CraftKeychain.value(account: key, service: service) { () -> String? in
|
|
995
|
+
var query = base
|
|
996
|
+
query[kSecReturnData as String] = true
|
|
997
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
998
|
+
var result: AnyObject?
|
|
999
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
1000
|
+
if status == errSecItemNotFound { return nil }
|
|
1001
|
+
guard status == errSecSuccess, let data = result as? Data else {
|
|
1002
|
+
failed = true
|
|
1003
|
+
return nil
|
|
1004
|
+
}
|
|
1005
|
+
return String(decoding: data, as: UTF8.self)
|
|
1006
|
+
}
|
|
1007
|
+
if failed {
|
|
1008
|
+
CraftKeychain.forget(account: key)
|
|
993
1009
|
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be read")
|
|
994
1010
|
}
|
|
995
|
-
return .success(
|
|
1011
|
+
return .success(value.map { $0 as Any } ?? NSNull())
|
|
996
1012
|
case "remove":
|
|
997
1013
|
let status = SecItemDelete(base as CFDictionary)
|
|
1014
|
+
CraftKeychain.forget(account: key)
|
|
998
1015
|
return status == errSecSuccess || status == errSecItemNotFound ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
|
|
999
1016
|
default:
|
|
1000
1017
|
return failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
|
|
@@ -1500,15 +1517,85 @@ extension CraftNativeActions {
|
|
|
1500
1517
|
/// `secureStore` writes it. Nil when there is none.
|
|
1501
1518
|
static func webSecureValue(forKey key: String) -> String? {
|
|
1502
1519
|
guard let key = validKey(key) else { return nil }
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1520
|
+
return CraftKeychain.value(account: key, service: nil) {
|
|
1521
|
+
let query: [String: Any] = [
|
|
1522
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
1523
|
+
kSecAttrAccount as String: key,
|
|
1524
|
+
kSecReturnData as String: true,
|
|
1525
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
1526
|
+
]
|
|
1527
|
+
var result: AnyObject?
|
|
1528
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
|
1529
|
+
return String(data: data, encoding: .utf8)
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/// The app's own Keychain items, read once and then remembered.
|
|
1535
|
+
///
|
|
1536
|
+
/// A Keychain read is `SecItemCopyMatching`, which took 100-900ms a read on a
|
|
1537
|
+
/// simulator and is not free on a phone, and every screen reads the sign-in
|
|
1538
|
+
/// token and the account beside it when it opens. They ran on the main
|
|
1539
|
+
/// thread, one after another, so a sign-in that made each open screen check
|
|
1540
|
+
/// the account again held the interface for seconds.
|
|
1541
|
+
///
|
|
1542
|
+
/// This process is the only writer of these items, through `secureStorage`,
|
|
1543
|
+
/// `CraftHybridKeychain` and the web bridge's secure storage, and each of
|
|
1544
|
+
/// them forgets what it changed. An item another process could write (a
|
|
1545
|
+
/// shared access group, an extension) is not remembered, and coming back to
|
|
1546
|
+
/// the foreground forgets everything, so a value written while the app was
|
|
1547
|
+
/// away is read fresh.
|
|
1548
|
+
enum CraftKeychain {
|
|
1549
|
+
/// Where native screens' secure storage runs: off the main thread, one
|
|
1550
|
+
/// call at a time, so a write is always seen by the read after it.
|
|
1551
|
+
static let queue = DispatchQueue(label: "craft.keychain", qos: .userInitiated)
|
|
1552
|
+
private static let lock = NSLock()
|
|
1553
|
+
private static var values: [String: String?] = [:]
|
|
1554
|
+
/// Moves on every forget, so a read that raced a write is not kept.
|
|
1555
|
+
private static var generation = 0
|
|
1556
|
+
private static var observing: NSObjectProtocol?
|
|
1557
|
+
|
|
1558
|
+
private static func cacheKey(_ account: String, _ service: String?) -> String {
|
|
1559
|
+
"\(service ?? "*")\u{1F}\(account)"
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/// `account`'s value under `service` (nil: whichever service holds it),
|
|
1563
|
+
/// from memory when it was read before, else from `read`.
|
|
1564
|
+
static func value(account: String, service: String?, read: () -> String?) -> String? {
|
|
1565
|
+
let key = cacheKey(account, service)
|
|
1566
|
+
lock.lock()
|
|
1567
|
+
if observing == nil { observeForeground() }
|
|
1568
|
+
if let known = values[key] {
|
|
1569
|
+
lock.unlock()
|
|
1570
|
+
return known
|
|
1571
|
+
}
|
|
1572
|
+
let started = generation
|
|
1573
|
+
lock.unlock()
|
|
1574
|
+
let value = read()
|
|
1575
|
+
lock.lock()
|
|
1576
|
+
if generation == started { values[key] = .some(value) }
|
|
1577
|
+
lock.unlock()
|
|
1578
|
+
return value
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/// Drop what is remembered for `account`, under every service; nil drops all.
|
|
1582
|
+
static func forget(account: String?) {
|
|
1583
|
+
lock.lock()
|
|
1584
|
+
generation += 1
|
|
1585
|
+
if let account {
|
|
1586
|
+
let suffix = "\u{1F}\(account)"
|
|
1587
|
+
values = values.filter { !$0.key.hasSuffix(suffix) }
|
|
1588
|
+
} else {
|
|
1589
|
+
values.removeAll()
|
|
1590
|
+
}
|
|
1591
|
+
lock.unlock()
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/// Called with `lock` held.
|
|
1595
|
+
private static func observeForeground() {
|
|
1596
|
+
observing = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in
|
|
1597
|
+
forget(account: nil)
|
|
1598
|
+
}
|
|
1512
1599
|
}
|
|
1513
1600
|
}
|
|
1514
1601
|
|