@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 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 blocking layer in native mode, with `visible`,
81
- `transparent`, `animationType`, `onShow`, `onDismiss`, and `onRequestClose`
82
- support. `FlatList` item roots receive the same `onLayout` callback as ordinary native
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
- non-grid, non-wrapped containers and retains a local fallback for older
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.254",
3
+ "version": "0.0.272",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 retained or prepared screen cannot clear another screen's chrome.
798
- func clearTabAccessory(ownedBy owner: AnyObject) {
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
- updateTabAccessory(nil)
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(Capsule())
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(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
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
- Capsule()
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
- let delta = abs(translation) >= 32 ? translation : velocity
1205
- guard abs(delta) >= 32 else { return }
1206
- let next = delta < 0 ? index + 1 : index - 1
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 > -700
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 = UINavigationController(rootViewController: root)
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
- if ["set", "remove", "delete", "clear"].contains(method) {
223
- sync = CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write)
224
- } else {
225
- sync = write()
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
- var query = base
987
- query[kSecReturnData as String] = true
988
- query[kSecMatchLimit as String] = kSecMatchLimitOne
989
- var result: AnyObject?
990
- let status = SecItemCopyMatching(query as CFDictionary, &result)
991
- if status == errSecItemNotFound { return .success(NSNull()) }
992
- guard status == errSecSuccess, let data = result as? Data else {
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(String(decoding: data, as: UTF8.self))
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
- let query: [String: Any] = [
1504
- kSecClass as String: kSecClassGenericPassword,
1505
- kSecAttrAccount as String: key,
1506
- kSecReturnData as String: true,
1507
- kSecMatchLimit as String: kSecMatchLimitOne,
1508
- ]
1509
- var result: AnyObject?
1510
- guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
1511
- return String(data: data, encoding: .utf8)
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