@craft-native/ios 0.0.424 → 0.0.436

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
@@ -52,6 +52,14 @@ decoration. Resuming does not replay it. Available on both native renderers.
52
52
  - **Audio Recording** - AVAudioRecorder with compressed output
53
53
  - **Video Recording** - UIImagePickerController video capture
54
54
 
55
+ ### Shake gesture configuration
56
+
57
+ `enableShakeGesture` is an opt-in boolean, separate from
58
+ `enableMotionSensors`. It defaults to false and arms the host's shake gesture
59
+ only while the app is in the foreground. Both configuration readers validate
60
+ its type: an invalid value rejects the configuration instead of letting the
61
+ Swift and Zig hosts disagree about which capabilities are enabled.
62
+
55
63
  ### Native voice capture
56
64
 
57
65
  With `enableAudioRecording`, the native renderer exposes
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ function iosPermissions(config) {
24
24
  contacts: config.enableContacts,
25
25
  calendar: config.enableCalendar,
26
26
  bluetooth: config.enableBluetooth || config.enableHeartRate,
27
- motion: config.enableMotionSensors,
27
+ motion: config.enableMotionSensors || config.enableShakeGesture,
28
28
  biometric: config.enableBiometric,
29
29
  music: config.enableAppleMusic,
30
30
  health: config.enableHealthKit,
@@ -504,6 +504,7 @@ var DEFAULT_CONFIG = {
504
504
  enableAudioRecording: false,
505
505
  enableVideoRecording: false,
506
506
  enableMotionSensors: false,
507
+ enableShakeGesture: false,
507
508
  enableLocalDatabase: false,
508
509
  enableBluetooth: false,
509
510
  enableNFC: false,
package/dist/index.d.ts CHANGED
@@ -84,6 +84,8 @@ export interface CraftConfig {
84
84
  enableAudioRecording?: boolean;
85
85
  enableVideoRecording?: boolean;
86
86
  enableMotionSensors?: boolean;
87
+ /** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
88
+ enableShakeGesture?: boolean;
87
89
  enableLocalDatabase?: boolean;
88
90
  enableBluetooth?: boolean;
89
91
  enableNFC?: boolean;
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ function iosPermissions(config) {
18
18
  contacts: config.enableContacts,
19
19
  calendar: config.enableCalendar,
20
20
  bluetooth: config.enableBluetooth || config.enableHeartRate,
21
- motion: config.enableMotionSensors,
21
+ motion: config.enableMotionSensors || config.enableShakeGesture,
22
22
  biometric: config.enableBiometric,
23
23
  music: config.enableAppleMusic,
24
24
  health: config.enableHealthKit,
@@ -701,6 +701,7 @@ var DEFAULT_CONFIG = {
701
701
  enableAudioRecording: false,
702
702
  enableVideoRecording: false,
703
703
  enableMotionSensors: false,
704
+ enableShakeGesture: false,
704
705
  enableLocalDatabase: false,
705
706
  enableBluetooth: false,
706
707
  enableNFC: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.424",
3
+ "version": "0.0.436",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1830,6 +1830,11 @@ struct CraftConfig: Codable {
1830
1830
  var enableAudioRecording: Bool = false
1831
1831
  var enableVideoRecording: Bool = false
1832
1832
  var enableMotionSensors: Bool = false
1833
+ /// Shaking the phone raises a `shake` event, for a report-a-problem
1834
+ /// gesture. Its own flag rather than a corner of `enableMotionSensors`,
1835
+ /// because it arms the accelerometer for as long as the app is in front:
1836
+ /// an app that wanted only the motion API should not pay for that.
1837
+ var enableShakeGesture: Bool = false
1833
1838
  var enableLocalDatabase: Bool = false
1834
1839
  var enableBluetooth: Bool = false
1835
1840
  var enableNFC: Bool = false
@@ -2413,6 +2418,10 @@ struct CraftWebView: UIViewRepresentable {
2413
2418
  private var motionManager: CMMotionManager?
2414
2419
  private var isMotionUpdating = false
2415
2420
 
2421
+ // Shake gesture (enableShakeGesture), armed only while the app is in front
2422
+ private var shakeMotion: CMMotionManager?
2423
+ private var lastShakeAt: Date?
2424
+
2416
2425
  // Bluetooth
2417
2426
  private var centralManager: CBCentralManager?
2418
2427
  private var peripheralManager: CBPeripheralManager?
@@ -2465,6 +2474,8 @@ struct CraftWebView: UIViewRepresentable {
2465
2474
  if config.enableMotionSensors {
2466
2475
  motionManager = CMMotionManager()
2467
2476
  }
2477
+ // The app starts in front, so the gesture starts armed.
2478
+ armShakeGesture()
2468
2479
  #endif
2469
2480
  if config.enableHealthKit {
2470
2481
  // Apple Health's heart rate as it arrives
@@ -3866,6 +3877,7 @@ struct CraftWebView: UIViewRepresentable {
3866
3877
  @objc private func appDidEnterBackground(_ notification: Notification) {
3867
3878
  backgroundedAt = Date()
3868
3879
  saveInteractionState()
3880
+ disarmShakeGesture()
3869
3881
  }
3870
3882
 
3871
3883
  @objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
@@ -3997,6 +4009,7 @@ struct CraftWebView: UIViewRepresentable {
3997
4009
  }
3998
4010
 
3999
4011
  @objc private func appWillEnterForeground(_ notification: Notification) {
4012
+ armShakeGesture()
4000
4013
  if recoverOnForeground, let webView {
4001
4014
  // The page is about to be rebuilt; there is no one to resume.
4002
4015
  backgroundedAt = nil
@@ -8499,6 +8512,56 @@ struct CraftWebView: UIViewRepresentable {
8499
8512
  #endif
8500
8513
  }
8501
8514
 
8515
+ // MARK: - Shake Gesture
8516
+
8517
+ /// The accelerometer, watched for a shake while the app is in front.
8518
+ ///
8519
+ /// Deliberately not `startDeviceMotionUpdates`: that fuses attitude and
8520
+ /// gravity every sample, and a shake needs neither. Raw acceleration at
8521
+ /// 20 Hz is enough to see one and costs a fraction of the same watch.
8522
+ ///
8523
+ /// Armed on foreground and disarmed on background, because the sensor
8524
+ /// is the whole cost of this feature and nobody can shake a phone at an
8525
+ /// app they cannot see.
8526
+ private func armShakeGesture() {
8527
+ #if CRAFT_PERMISSION_MOTION
8528
+ guard config.enableShakeGesture, shakeMotion == nil else { return }
8529
+ let manager = CMMotionManager()
8530
+ guard manager.isAccelerometerAvailable else { return }
8531
+ manager.accelerometerUpdateInterval = 1.0 / 20.0
8532
+ shakeMotion = manager
8533
+ manager.startAccelerometerUpdates(to: .main) { [weak self] sample, _ in
8534
+ guard let self, let sample else { return }
8535
+ self.readShake(sample.acceleration)
8536
+ }
8537
+ #endif
8538
+ }
8539
+
8540
+ private func disarmShakeGesture() {
8541
+ #if CRAFT_PERMISSION_MOTION
8542
+ shakeMotion?.stopAccelerometerUpdates()
8543
+ shakeMotion = nil
8544
+ #endif
8545
+ }
8546
+
8547
+ #if CRAFT_PERMISSION_MOTION
8548
+ private func readShake(_ force: CMAcceleration) {
8549
+ // Total force, gravity included, so a phone at rest reads 1g and
8550
+ // the threshold is what a deliberate shake adds on top of it. At
8551
+ // 2.2g a pocket, a footfall and setting the phone down are all
8552
+ // below it, which is what a fitness app has to clear.
8553
+ let magnitude = sqrt(force.x * force.x + force.y * force.y + force.z * force.z)
8554
+ guard magnitude >= 2.2 else { return }
8555
+ let now = Date()
8556
+ // One event per gesture, not one per sample. A shake is a second
8557
+ // or two of samples over the threshold, and every one of them
8558
+ // would otherwise be reported as its own shake.
8559
+ if let lastShakeAt, now.timeIntervalSince(lastShakeAt) < 2.0 { return }
8560
+ lastShakeAt = now
8561
+ sendToWeb("shake", data: ["force": magnitude])
8562
+ }
8563
+ #endif
8564
+
8502
8565
  // MARK: - Motion Sensors
8503
8566
  private func startMotionUpdates(interval: Double, callbackId: String?) {
8504
8567
  #if CRAFT_PERMISSION_MOTION
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
18
18
  struct CraftNativeMutationResult {
19
19
  let batchId: String
20
20
  let revision: Int
21
- let document: [String: Any]?
22
21
  let updatedNodeIds: [String]
23
22
  let affectedNodeIds: [String]
24
23
  let requiresFullRender: Bool
24
+ private let materialize: () -> [String: Any]?
25
+ /// The committed tree, built when asked for: a host that redraws only the
26
+ /// nodes a batch touched never needs the whole tree.
27
+ var document: [String: Any]? { materialize() }
28
+
29
+ init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
30
+ self.batchId = batchId
31
+ self.revision = revision
32
+ self.materialize = document
33
+ self.updatedNodeIds = updatedNodeIds
34
+ self.affectedNodeIds = affectedNodeIds
35
+ self.requiresFullRender = requiresFullRender
36
+ }
25
37
  }
26
38
 
27
39
  /// Validates and applies versioned native-tree mutations without touching UIKit.
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
91
103
  var updatedNodeIds: [String] = []
92
104
  var affectedNodeIds: [String] = []
93
105
  var requiresFullRender = false
106
+ // Only an operation that adds, moves or removes nodes can leave one unreachable.
107
+ var reshaped = false
94
108
  for (index, value) in operations.enumerated() {
95
109
  guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
96
110
  throw CraftNativeMutationFailure(
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
99
113
  operationIndex: index
100
114
  )
101
115
  }
116
+ if name != "updateNode" { reshaped = true }
102
117
  do {
103
118
  switch name {
104
119
  case "createNode":
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
147
162
  guard let root = candidateNodes[candidateRoot], root.parent == nil else {
148
163
  throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
149
164
  }
150
- guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
165
+ guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
151
166
  throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
152
167
  }
153
168
  }
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
158
173
  return CraftNativeMutationResult(
159
174
  batchId: batchId,
160
175
  revision: nextRevision,
161
- document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
176
+ document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
162
177
  updatedNodeIds: updatedNodeIds,
163
178
  affectedNodeIds: affectedNodeIds,
164
179
  requiresFullRender: requiresFullRender
@@ -2716,6 +2716,8 @@ enum CraftNativeFlexLayout {
2716
2716
  ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
2717
2717
  : maxContent.height)
2718
2718
  return CGSize(width: resolvedWidth, height: resolvedHeight)
2719
+ case let image as CraftNativePositionedImageView where image.image == nil:
2720
+ return CGSize(width: width ?? 0, height: height ?? 0)
2719
2721
  case let image as UIImageView where image.image == nil:
2720
2722
  return CGSize(width: width ?? 0, height: height ?? 0)
2721
2723
  default:
@@ -4165,25 +4167,37 @@ private extension UIImage {
4165
4167
  }
4166
4168
 
4167
4169
  /// Explicit CSS-facing image geometry; the loader and semantic view stay retained.
4168
- class CraftNativePositionedImageView: UIImageView {
4170
+ class CraftNativePositionedImageView: UIView {
4169
4171
  private var storedImage: UIImage?
4170
- private var fittedImage: UIImageView?
4172
+ private let fittedImage = UIImageView()
4171
4173
  private var fit: String?
4172
4174
  private var position = CGPoint(x: 0.5, y: 0.5)
4173
4175
 
4174
- override var image: UIImage? {
4176
+ override init(frame: CGRect) {
4177
+ super.init(frame: frame)
4178
+ isUserInteractionEnabled = false
4179
+ fittedImage.isUserInteractionEnabled = false
4180
+ fittedImage.isAccessibilityElement = false
4181
+ addSubview(fittedImage)
4182
+ }
4183
+
4184
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
4185
+
4186
+ // Keep source identity separate from UIKit's image renderer. A UIImageView
4187
+ // host with this stored-image getter painted an extra copy even with
4188
+ // super.image set to nil; only the child owns image drawing now.
4189
+ var image: UIImage? {
4175
4190
  get { storedImage }
4176
4191
  set {
4177
4192
  storedImage = newValue
4178
- super.image = fit == nil ? newValue : nil
4179
- fittedImage?.image = newValue
4193
+ fittedImage.image = newValue
4180
4194
  invalidateIntrinsicContentSize()
4181
4195
  setNeedsLayout()
4182
4196
  }
4183
4197
  }
4184
4198
 
4185
4199
  override var intrinsicContentSize: CGSize {
4186
- fit == nil ? super.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4200
+ fit == nil ? fittedImage.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4187
4201
  }
4188
4202
 
4189
4203
  func configureObjectGeometry(props: [String: Any], style: [String: Any]) {
@@ -4197,22 +4211,28 @@ class CraftNativePositionedImageView: UIImageView {
4197
4211
  let requested = rawFit as? String ?? "fill"
4198
4212
  fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4199
4213
  position = Self.objectPosition(rawPosition)
4200
- if fittedImage == nil {
4201
- let child = UIImageView()
4202
- child.isUserInteractionEnabled = false
4203
- child.isAccessibilityElement = false
4204
- child.contentMode = .scaleToFill
4205
- insertSubview(child, at: 0)
4206
- fittedImage = child
4207
- }
4208
4214
  } else { fit = nil }
4209
- fittedImage?.isHidden = fit == nil
4210
- super.image = fit == nil ? storedImage : nil
4211
- fittedImage?.image = storedImage
4215
+ fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
4212
4216
  invalidateIntrinsicContentSize()
4213
4217
  setNeedsLayout()
4214
4218
  }
4215
4219
 
4220
+ override var contentMode: UIView.ContentMode {
4221
+ didSet {
4222
+ fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
4223
+ setNeedsLayout()
4224
+ }
4225
+ }
4226
+
4227
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
4228
+ fit == nil ? fittedImage.sizeThatFits(size) : intrinsicContentSize
4229
+ }
4230
+
4231
+ override func tintColorDidChange() {
4232
+ super.tintColorDidChange()
4233
+ fittedImage.tintColor = tintColor
4234
+ }
4235
+
4216
4236
  var usesObjectGeometry: Bool { fit != nil }
4217
4237
  var objectCovers: Bool { fit == "cover" }
4218
4238
 
@@ -4234,7 +4254,8 @@ class CraftNativePositionedImageView: UIImageView {
4234
4254
 
4235
4255
  override func layoutSubviews() {
4236
4256
  super.layoutSubviews()
4237
- guard let fit, let child = fittedImage else { return }
4257
+ let child = fittedImage
4258
+ guard let fit else { child.frame = bounds; return }
4238
4259
  let source = storedImage?.craftIntrinsicSize ?? .zero
4239
4260
  guard source.width > 0, source.height > 0 else { child.frame = .zero; return }
4240
4261
  let contain = min(bounds.width / source.width, bounds.height / source.height)
@@ -9203,7 +9224,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9203
9224
  case "Icon":
9204
9225
  configureIcon(result as! UIImageView, props: props, style: style)
9205
9226
  case "Image":
9206
- let image = result as! UIImageView
9227
+ let image = result as! CraftNativePositionedImageView
9207
9228
  let imageID = ObjectIdentifier(image)
9208
9229
  if let tint = color(style["tintColor"]) {
9209
9230
  tintedImages.insert(imageID)
@@ -9215,7 +9236,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9215
9236
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
9216
9237
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
9217
9238
  image.contentMode = imageContentMode(requestedResizeMode)
9218
- (image as? CraftNativePositionedImageView)?.configureObjectGeometry(props: props, style: style)
9239
+ image.configureObjectGeometry(props: props, style: style)
9219
9240
  applyImageClipping(image, contentMode: image.contentMode, style: style)
9220
9241
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
9221
9242
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
@@ -10347,7 +10368,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10347
10368
  view.clipsToBounds = overflow == "hidden"
10348
10369
  || (view is UIScrollView && overflow != "visible")
10349
10370
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
10350
- || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
10371
+ || ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
10351
10372
  // Per-corner rounding clips its content, as its mask did.
10352
10373
  || hasCornerRadii
10353
10374
  view.layer.masksToBounds = view.clipsToBounds
@@ -10659,7 +10680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10659
10680
  /// The pixels a picture is drawn at, when its style fixes its size (a
10660
10681
  /// 40-point avatar, a card's photo): nil otherwise, for a picture whose
10661
10682
  /// own size lays it out, which must keep its full size.
10662
- static func displayPixels(of view: UIImageView) -> (size: CGSize, scale: CGFloat)? {
10683
+ static func displayPixels(of view: UIView) -> (size: CGSize, scale: CGFloat)? {
10663
10684
  let style = view.craftLayoutStyle
10664
10685
  let points: CGSize
10665
10686
  switch (style.width, style.height, style.aspectRatio) {
@@ -10742,16 +10763,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10742
10763
  return candidate
10743
10764
  }
10744
10765
 
10745
- private func applyImageClipping(_ view: UIImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
10766
+ private func applyImageClipping(_ view: CraftNativePositionedImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
10746
10767
  let authored = (style["overflow"] as? String) == "hidden"
10747
10768
  || (view.craftRequestedCornerRadius ?? 0) > 0
10748
10769
  || view.craftRequestedCornerRadii != nil
10749
- let clips = authored || contentMode == .scaleAspectFill || (view as? CraftNativePositionedImageView)?.usesObjectGeometry == true
10770
+ let clips = authored || contentMode == .scaleAspectFill || view.usesObjectGeometry
10750
10771
  view.clipsToBounds = clips
10751
10772
  view.layer.masksToBounds = clips
10752
10773
  }
10753
10774
 
10754
- private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
10775
+ private func updateImage(_ view: CraftNativePositionedImageView, source: Any?, defaultSource: Any? = nil) {
10755
10776
  let sourceObject = source as? [String: Any]
10756
10777
  let uri = (source as? String) ?? sourceObject?["uri"] as? String
10757
10778
  let headers = (sourceObject?["headers"] as? [String: Any])?.reduce(into: [String: String]()) { result, entry in
@@ -10793,7 +10814,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10793
10814
  if let url = imageURL(for: uri) {
10794
10815
  // Decoded at the size it is drawn, when the style fixes that size.
10795
10816
  let pixels = Self.displayPixels(of: view)
10796
- let fill = (view as? CraftNativePositionedImageView)?.objectCovers == true || view.contentMode == .scaleAspectFill
10817
+ let fill = view.objectCovers || view.contentMode == .scaleAspectFill
10797
10818
  // media.keep owns private/offline copies keyed by the authored URL.
10798
10819
  // Read before the decoded/network cache, without retaining private
10799
10820
  // pixels there after the app clears its media on sign-out.
@@ -10884,7 +10905,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10884
10905
  return !uri.contains(":") ? UIImage(named: uri) : nil
10885
10906
  }
10886
10907
 
10887
- private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
10908
+ private func imageFailure(_ view: CraftNativePositionedImageView, uri: String?, message: String) {
10888
10909
  imageErrors[ObjectIdentifier(view)] = message
10889
10910
  view.image = nil
10890
10911
  imageDidChange(view)
@@ -10895,19 +10916,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10895
10916
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
10896
10917
  }
10897
10918
 
10898
- private func imageDidChange(_ view: UIImageView) {
10919
+ private func imageDidChange(_ view: CraftNativePositionedImageView) {
10899
10920
  view.invalidateIntrinsicContentSize()
10900
10921
  CraftNativeFlexLayout.invalidateAncestors(of: view)
10901
10922
  }
10902
10923
 
10903
- private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
10924
+ private func imageForDisplay(_ image: UIImage, view: CraftNativePositionedImageView) -> UIImage {
10904
10925
  let displayed = image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10905
10926
  displayed.craftIntrinsicSize = image.craftIntrinsicSize
10906
10927
  return displayed
10907
10928
  }
10908
10929
 
10909
10930
  private func emitImageEvent(
10910
- _ view: UIImageView,
10931
+ _ view: CraftNativePositionedImageView,
10911
10932
  handler: String?,
10912
10933
  nativeEvent: [String: Any] = [:]
10913
10934
  ) {
@@ -10961,7 +10982,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10961
10982
  switch role ?? type.lowercased() {
10962
10983
  case "tablist": traits.insert(.tabBar)
10963
10984
  case "button", "checkbox": traits.insert(.button)
10964
- case "image": traits.insert(.image)
10985
+ case "image", "imagebackground": traits.insert(.image)
10965
10986
  case "header": traits.insert(.header)
10966
10987
  case "link": traits.insert(.link)
10967
10988
  case "search": traits.insert(.searchField)
@@ -11023,11 +11044,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11023
11044
 
11024
11045
  @objc private func buttonPressed(_ sender: UIButton) {
11025
11046
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
11026
- if let target = linkTargets[ObjectIdentifier(sender)] {
11027
- hybridEvents?.nativeScreen(self, open: target, props: [:])
11047
+ let id = ObjectIdentifier(sender)
11048
+ // An authored handler owns navigation; the asynchronous bridge cannot
11049
+ // wait for preventDefault before opening the link.
11050
+ guard let handler = handlers[id] else {
11051
+ if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
11028
11052
  return
11029
11053
  }
11030
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
11031
11054
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
11032
11055
  restoreFocus(focused)
11033
11056
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -11191,7 +11214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11191
11214
 
11192
11215
  @objc private func solidSwitchChanged(_ sender: CraftNativeSolidSwitch) {
11193
11216
  guard sender.isEnabled, let handler = handlers[ObjectIdentifier(sender)] else { return }
11194
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
11217
+ sendAfterThumb(handler: handler, value: sender.isOn)
11195
11218
  }
11196
11219
 
11197
11220
  @objc private func switchChanged(_ sender: UISwitch) {
@@ -11202,7 +11225,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11202
11225
  self.switchUserValues.removeValue(forKey: id)
11203
11226
  }
11204
11227
  guard let handler = handlers[id] else { return }
11205
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
11228
+ sendAfterThumb(handler: handler, value: sender.isOn)
11229
+ }
11230
+
11231
+ /// A switch's value, told to the script after UIKit has been left to draw
11232
+ /// the thumb.
11233
+ ///
11234
+ /// On the default main-thread script a handler runs inline, inside the
11235
+ /// control's own action, so the touch's CATransaction stays open until the
11236
+ /// script has finished its work and whatever render it causes. UIKit has
11237
+ /// already set the new value by then, so the thumb does not travel: the
11238
+ /// switch sits still for as long as the script takes and then appears in
11239
+ /// its new state. Yielding the runloop turn first lets that transaction
11240
+ /// commit, the animation is handed to the render server, and the script's
11241
+ /// work no longer holds the thumb. A background script never blocks the
11242
+ /// main thread, so it is told at once.
11243
+ private func sendAfterThumb(handler: String, value: Bool) {
11244
+ let payload: [String: Any] = ["handlerName": handler, "nativeEvent": ["value": value]]
11245
+ guard !CraftNativeScriptThread.isBackground else {
11246
+ send(type: "EVENT", payload: payload)
11247
+ return
11248
+ }
11249
+ DispatchQueue.main.async { [weak self] in self?.send(type: "EVENT", payload: payload) }
11206
11250
  }
11207
11251
 
11208
11252
  @objc private func sliderChanged(_ sender: UISlider) {
@@ -11385,11 +11429,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11385
11429
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
11386
11430
  guard let view = sender.view else { return }
11387
11431
  let id = ObjectIdentifier(view)
11388
- if let target = linkTargets[id] {
11389
- hybridEvents?.nativeScreen(self, open: target, props: [:])
11432
+ guard let handler = handlers[id] else {
11433
+ if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
11390
11434
  return
11391
11435
  }
11392
- guard let handler = handlers[id] else { return }
11393
11436
  if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
11394
11437
  let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
11395
11438
  UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
@@ -69,6 +69,7 @@ final class CraftVoiceRecording {
69
69
  var recorder: CraftVoiceRecorder? // audio queue only
70
70
  var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
71
71
  var deadline: DispatchWorkItem?
72
+ var stopping = false // main only
72
73
  private let lock = NSLock()
73
74
  private var cancelled = false
74
75
  init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
@@ -86,15 +87,29 @@ final class CraftVoiceRecording {
86
87
  }
87
88
  }
88
89
  private var current: Session?
90
+ /// Each microphone step in the unified log (`subsystem == "craft.native"`,
91
+ /// category `voice-recording`), so a recording that never finished says
92
+ /// whether its stop arrived, and where AVFoundation held it.
93
+ private static func trace(_ message: String) {
94
+ CraftNativeConsole.write("info", category: "voice-recording", message: message)
95
+ }
89
96
  private let audioQueue: DispatchQueue
90
97
  private let permission: (@escaping (Bool) -> Void) -> Void
91
98
  private let makeRecorder: (URL) throws -> CraftVoiceRecorder
92
99
  private let deactivate: () -> Void
100
+ /// Releasing the audio session runs here, never on `audioQueue`: a
101
+ /// deactivation that stalls (seen on simulators with a virtual input)
102
+ /// must not hold the next recording's start or stop behind it.
103
+ private let releaseQueue = DispatchQueue(label: "craft.voice-recording-release", qos: .utility)
104
+ private let generationLock = NSLock()
105
+ private var generation = 0
93
106
  private let startupTimeout: TimeInterval
107
+ private let stopTimeout: TimeInterval
94
108
  private var backgroundObserver: NSObjectProtocol?
95
109
 
96
110
  init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
97
111
  startupTimeout: TimeInterval = 15,
112
+ stopTimeout: TimeInterval = 10,
98
113
  permission: @escaping (@escaping (Bool) -> Void) -> Void = { answer in
99
114
  #if CRAFT_PERMISSION_MICROPHONE
100
115
  AVAudioSession.sharedInstance().requestRecordPermission(answer)
@@ -116,6 +131,7 @@ final class CraftVoiceRecording {
116
131
  Self.prepareForLaunch()
117
132
  self.audioQueue = audioQueue
118
133
  self.startupTimeout = startupTimeout
134
+ self.stopTimeout = stopTimeout
119
135
  self.permission = permission
120
136
  self.makeRecorder = makeRecorder
121
137
  self.deactivate = deactivate
@@ -128,7 +144,17 @@ final class CraftVoiceRecording {
128
144
  session.recorder?.stop()
129
145
  session.recorder = nil
130
146
  if let file = session.file { CraftNativeFiles.remove(file.path) }
131
- deactivate()
147
+ releaseAudioSession()
148
+ }
149
+
150
+ /// Deactivate off the recording queue, and not at all once a newer
151
+ /// recording has started: it would only take the microphone from it.
152
+ private func releaseAudioSession() {
153
+ let mine = generationLock.withLock { generation }
154
+ releaseQueue.async { [weak self] in
155
+ guard let self, self.generationLock.withLock({ self.generation }) == mine else { return }
156
+ self.deactivate()
157
+ }
132
158
  }
133
159
 
134
160
  private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
@@ -150,6 +176,7 @@ final class CraftVoiceRecording {
150
176
  private func performOnMain(method: String, args: [Any], isActive: @escaping () -> Bool, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
151
177
  func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
152
178
  guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
179
+ Self.trace("\(method) \(key.prefix(8)) (current \(current.map { String($0.key.prefix(8)) } ?? "none"))")
153
180
  if method == "cancel" {
154
181
  if current?.key == key { clear() }
155
182
  completion(.success(true))
@@ -157,11 +184,36 @@ final class CraftVoiceRecording {
157
184
  }
158
185
  if method == "stop" {
159
186
  guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
187
+ guard !session.stopping else { fail("RECORDING_BUSY", "This recording is already finishing"); return }
188
+ session.stopping = true
189
+ // Answered exactly once: by the encoder's result, or by the
190
+ // deadline when AVFoundation never returns (a stalled encoder or
191
+ // audio route), which then erases the recording like a cancel.
192
+ var settled = false
193
+ var timeout: DispatchWorkItem?
194
+ func settle(_ result: Result<Any, CraftNativeActionError>) {
195
+ timeout?.cancel()
196
+ guard !settled else { return }
197
+ settled = true
198
+ completion(result)
199
+ }
200
+ let late = CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The recording took too long to finish. Please try again.")
201
+ timeout = DispatchWorkItem { [weak self] in
202
+ guard !settled else { return }
203
+ Self.trace("stop \(key.prefix(8)) timed out waiting for the encoder")
204
+ if let self, self.current === session { self.clear(late) }
205
+ settle(.failure(late))
206
+ }
207
+ DispatchQueue.main.asyncAfter(deadline: .now() + stopTimeout, execute: timeout!)
160
208
  audioQueue.async {
161
- guard !session.isCancelled else { return DispatchQueue.main.async { fail("RECORDING_CANCELLED", "Recording was cancelled") } }
209
+ guard !session.isCancelled else {
210
+ return DispatchQueue.main.async { settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled"))) }
211
+ }
162
212
  session.recorder?.stop()
163
213
  session.recorder = nil
164
- self.deactivate()
214
+ Self.trace("stop \(key.prefix(8)): encoder stopped")
215
+ // Read the finished file before releasing the audio session:
216
+ // the answer must not wait on a deactivation that can stall.
165
217
  let result: Result<Any, CraftNativeActionError>
166
218
  if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
167
219
  let duration = Double(audio.length) / audio.processingFormat.sampleRate
@@ -170,14 +222,17 @@ final class CraftVoiceRecording {
170
222
  result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
171
223
  }
172
224
  DispatchQueue.main.async {
225
+ guard !settled else { return }
173
226
  guard self.current === session, isActive(), !session.isCancelled else {
174
227
  if self.current === session { self.clear() }
175
- fail("RECORDING_CANCELLED", "Recording was cancelled")
228
+ settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
176
229
  return
177
230
  }
178
231
  if case .failure = result { self.clear() }
179
- completion(result)
232
+ Self.trace("stop \(key.prefix(8)) answered")
233
+ settle(result)
180
234
  }
235
+ self.releaseAudioSession()
181
236
  }
182
237
  return
183
238
  }
@@ -209,6 +264,7 @@ final class CraftVoiceRecording {
209
264
  self.audioQueue.async {
210
265
  do {
211
266
  guard !session.isCancelled else { return }
267
+ self.generationLock.withLock { self.generation += 1 }
212
268
  session.recorder = try self.makeRecorder(file)
213
269
  guard !session.isCancelled else { self.erase(session); return }
214
270
  guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
@@ -220,6 +276,7 @@ final class CraftVoiceRecording {
220
276
  if self.current === session { self.clear() }
221
277
  return
222
278
  }
279
+ Self.trace("start \(key.prefix(8)): recording")
223
280
  session.finish(.success(true))
224
281
  }
225
282
  }
@@ -1,6 +1,8 @@
1
1
  #import "CraftTimedInput.h"
2
2
  #import <UIKit/UIKit.h>
3
3
  #import <math.h>
4
+ #import <dlfcn.h>
5
+ #import <XCTest/XCTest.h>
4
6
 
5
7
  // XCTest's own touch-record API is private. Look up classes and check every
6
8
  // selector at runtime; unsupported Xcode versions fail rather than skipping or
@@ -12,6 +14,16 @@
12
14
  - (id)initWithName:(NSString *)name interfaceOrientation:(NSInteger)orientation;
13
15
  - (void)addPointerEventPath:(id)path;
14
16
  - (BOOL)synthesizeWithError:(NSError **)error;
17
+ + (id)eventStreamWithEventActions:(void (^)(id))actions;
18
+ - (void)advanceTime:(double)seconds;
19
+ - (void)touchDown:(CGPoint)point;
20
+ - (void)liftUp:(CGPoint)point;
21
+ - (id)underlyingPlayer;
22
+ - (BOOL)prewarmForEventStream:(id)stream withError:(NSError **)error;
23
+ - (void)setNeedsInitialDelay:(BOOL)value;
24
+ - (void)setIgnoresCompletionDelay:(BOOL)value;
25
+ - (void)playEventStream:(id)stream options:(id)options completion:(void (^)(void))completion;
26
+ - (void)tearDown;
15
27
  @end
16
28
 
17
29
  @implementation CraftTimedInput
@@ -21,6 +33,68 @@
21
33
  return NO;
22
34
  }
23
35
 
36
+ // The simulator's XCTest touch-path translator omits idle intervals between
37
+ // fingers and rounds stationary moves to its sample interval. Submit stationary,
38
+ // non-overlapping taps as one native HID stream with explicit clock advances.
39
+ // Moving/overlapping paths retain the existing XCTest gesture implementation.
40
+ + (BOOL)synthesizeStationaryPaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
41
+ static dispatch_once_t once;
42
+ static void *recap;
43
+ dispatch_once(&once, ^{
44
+ recap = dlopen("/System/Library/PrivateFrameworks/Recap.framework/Recap", RTLD_NOW | RTLD_LOCAL);
45
+ });
46
+ Class streamClass = NSClassFromString(@"RCPSyntheticEventStream");
47
+ Class playerClass = NSClassFromString(@"RCPInlinePlayer");
48
+ if (!recap || ![streamClass respondsToSelector:@selector(eventStreamWithEventActions:)])
49
+ return [self fail:@"Native timed tap streams are unavailable on this runtime" error:error];
50
+ for (NSString *name in @[@"advanceTime:", @"touchDown:", @"liftUp:"])
51
+ if (![streamClass instancesRespondToSelector:NSSelectorFromString(name)])
52
+ return [self fail:@"Native timed tap stream cannot preserve the schedule" error:error];
53
+ for (NSString *name in @[@"underlyingPlayer", @"setNeedsInitialDelay:", @"setIgnoresCompletionDelay:",
54
+ @"playEventStream:options:completion:", @"tearDown"])
55
+ if (![playerClass instancesRespondToSelector:NSSelectorFromString(name)])
56
+ return [self fail:@"Native timed tap playback is unavailable" error:error];
57
+
58
+ id stream = [streamClass eventStreamWithEventActions:^(id builder) {
59
+ double previous = 0;
60
+ for (NSArray *points in paths) {
61
+ NSDictionary *first = points.firstObject, *last = points.lastObject;
62
+ double start = [first[@"at"] doubleValue], end = [last[@"at"] doubleValue];
63
+ CGPoint point = CGPointMake(frame.origin.x + [first[@"x"] doubleValue] * frame.size.width,
64
+ frame.origin.y + [first[@"y"] doubleValue] * frame.size.height);
65
+ [builder advanceTime:start - previous];
66
+ [builder touchDown:point];
67
+ [builder advanceTime:end - start];
68
+ [builder liftUp:point];
69
+ previous = end;
70
+ }
71
+ }];
72
+ if (!stream) return [self fail:@"Native timed tap stream could not be constructed" error:error];
73
+ id player = [playerClass new];
74
+ id underlying = [player underlyingPlayer];
75
+ if (![underlying respondsToSelector:@selector(prewarmForEventStream:withError:)]) {
76
+ [player tearDown];
77
+ return [self fail:@"Native timed tap delivery cannot be prepared" error:error];
78
+ }
79
+ // Preparing the HID delivery service emits no touches. Do not add a warm-up
80
+ // touch or an initial/completion sleep to make the receiver appear reliable.
81
+ if (![underlying prewarmForEventStream:stream withError:error]) {
82
+ [player tearDown];
83
+ return NO;
84
+ }
85
+ [player setNeedsInitialDelay:NO];
86
+ [player setIgnoresCompletionDelay:YES];
87
+ XCTestExpectation *done = [[XCTestExpectation alloc] initWithDescription:@"Native timed tap completion"];
88
+ [player playEventStream:stream options:nil completion:^{
89
+ [player tearDown];
90
+ [done fulfill];
91
+ }];
92
+ // Validated streams end within 12s. A timeout is a failure, never a retry.
93
+ if ([XCTWaiter waitForExpectations:@[done] timeout:20] != XCTWaiterResultCompleted)
94
+ return [self fail:@"Native timed tap playback did not complete" error:error];
95
+ return YES;
96
+ }
97
+
24
98
  + (BOOL)synthesizePaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
25
99
  if (![paths isKindOfClass:NSArray.class] || paths.count == 0 || paths.count > 64
26
100
  || CGRectIsEmpty(frame) || !isfinite(frame.size.width) || !isfinite(frame.size.height))
@@ -43,6 +117,21 @@
43
117
  previous = at;
44
118
  }
45
119
  }
120
+ NSArray *ordered = [paths sortedArrayUsingComparator:^NSComparisonResult(NSArray *left, NSArray *right) {
121
+ return [left.firstObject[@"at"] compare:right.firstObject[@"at"]];
122
+ }];
123
+ BOOL stationary = [ordered.firstObject[0][@"at"] doubleValue] == 0;
124
+ double previousEnd = 0;
125
+ for (NSArray *points in ordered) {
126
+ NSDictionary *first = points.firstObject;
127
+ if ([first[@"at"] doubleValue] < previousEnd) stationary = NO;
128
+ for (NSDictionary *point in points)
129
+ if ([point[@"x"] doubleValue] != [first[@"x"] doubleValue]
130
+ || [point[@"y"] doubleValue] != [first[@"y"] doubleValue]) stationary = NO;
131
+ previousEnd = [points.lastObject[@"at"] doubleValue];
132
+ }
133
+ if (stationary) return [self synthesizeStationaryPaths:ordered frame:frame error:error];
134
+
46
135
  Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
47
136
  Class pathClass = NSClassFromString(@"XCPointerEventPath");
48
137
  for (NSString *name in @[@"initWithName:interfaceOrientation:", @"addPointerEventPath:", @"synthesizeWithError:"])
@@ -68,7 +157,7 @@
68
157
  [path liftUpAtOffset:[points.lastObject[@"at"] doubleValue]];
69
158
  [record addPointerEventPath:path];
70
159
  }
71
- // One dispatch retains the native offsets between different fingers/taps.
160
+ // One dispatch preserves the existing moving/overlapping gesture path.
72
161
  // Individual XCUICoordinate.tap() calls would wait for app quiescence.
73
162
  return [record synthesizeWithError:error];
74
163
  }
@@ -42,7 +42,7 @@ import Darwin
42
42
  /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
43
43
  /// selected <tab> the tab bar's selected button is this one
44
44
  /// tab <name> tap a tab bar button
45
- /// scrollto <text> swipes up (at most 8 times) until text is hittable
45
+ /// scrollto <text> at most 12 drags until the target is hittable and visible
46
46
  /// swipe up|down|left|right
47
47
  /// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
48
48
  /// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
@@ -143,13 +143,29 @@ final class DriverTests: XCTestCase {
143
143
  return any.exists ? any : nil
144
144
  }
145
145
 
146
+ /// Where a label is looked for, in order: the topmost presented alert,
147
+ /// then the topmost sheet, then the whole app. A sheet's "Sign out" and
148
+ /// the row that opened it share a label, and the row behind the sheet
149
+ /// is still reported hittable, so the presented one has to win.
150
+ func searchScopes<Scope>(alerts: [Scope], sheets: [Scope], app: Scope) -> [Scope] {
151
+ [alerts.last, sheets.last].compactMap { $0 } + [app]
152
+ }
153
+
154
+ func presentedScopes() -> [XCUIElement] {
155
+ let alerts = app.alerts.allElementsBoundByIndex.filter { $0.exists }
156
+ let sheets = app.sheets.allElementsBoundByIndex.filter { $0.exists }
157
+ return searchScopes(alerts: alerts, sheets: sheets, app: app as XCUIElement)
158
+ }
159
+
146
160
  func exact(_ label: String) -> XCUIElement? {
147
161
  let predicate = NSPredicate(format: "label == %@", label)
148
- for query in [app.buttons, app.links, app.staticTexts] {
149
- let matches = query.matching(predicate)
150
- for index in 0..<min(matches.count, 25) {
151
- let candidate = matches.element(boundBy: index)
152
- if candidate.exists && candidate.isHittable { return candidate }
162
+ for scope in presentedScopes() {
163
+ for query in [scope.buttons, scope.links, scope.staticTexts] {
164
+ let matches = query.matching(predicate)
165
+ for index in 0..<min(matches.count, 25) {
166
+ let candidate = matches.element(boundBy: index)
167
+ if candidate.exists && candidate.isHittable { return candidate }
168
+ }
153
169
  }
154
170
  }
155
171
  return nil
@@ -164,6 +180,27 @@ final class DriverTests: XCTestCase {
164
180
  return nil
165
181
  }
166
182
 
183
+ /// A short target fits entirely; an oversized target fills the viewport.
184
+ func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
185
+ let visible = target.intersection(viewport)
186
+ return !target.isEmpty && !viewport.isEmpty && !visible.isNull
187
+ && visible.width >= min(target.width, viewport.width)
188
+ && visible.height >= min(target.height, viewport.height)
189
+ }
190
+
191
+ func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
192
+ guard let target, target.exists, target.isHittable,
193
+ visibleScrollFrame(target.frame, in: app.frame) else { return false }
194
+ // Hittable alone accepts controls clipped by the scroll view's edge.
195
+ // Only an ancestor scroll view clips this target, not its siblings.
196
+ let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
197
+ for scroll in app.scrollViews.allElementsBoundByIndex {
198
+ if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
199
+ !visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
200
+ }
201
+ return true
202
+ }
203
+
167
204
  /// About a third of a screen of scrolling, as a held drag.
168
205
  ///
169
206
  /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
@@ -248,8 +285,15 @@ final class DriverTests: XCTestCase {
248
285
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
249
286
  }
250
287
 
251
- /// A tap that never fails the run: an element covered a moment (the big
252
- /// timer coming up) is tapped where it is, and the check after says.
288
+ /// A switch reports "0" or "1". Any other value is not a switch state to
289
+ /// wait on, so the tap stands as it would for any other control.
290
+ func switchFlipped(from before: String?, to after: String?) -> Bool {
291
+ let states: Set<String> = ["0", "1"]
292
+ guard let before, let after, states.contains(before), states.contains(after) else { return true }
293
+ return before != after
294
+ }
295
+
296
+ /// Deliver one gesture; the command's observation determines success.
253
297
  func press(_ element: XCUIElement) {
254
298
  if element.isHittable { element.tap() }
255
299
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
@@ -401,8 +445,21 @@ final class DriverTests: XCTestCase {
401
445
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
402
446
  case "tapid":
403
447
  let target = identified(arg)
404
- if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
405
- else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
448
+ if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
449
+ else if target.elementType == .switch {
450
+ // Observe one physical gesture. Retapping could conceal a
451
+ // renderer or hit-testing failure (HQ agreement job 7570).
452
+ let before = target.value as? String
453
+ press(target)
454
+ let settle = Date().addingTimeInterval(1.5)
455
+ var flipped = false
456
+ repeat {
457
+ flipped = switchFlipped(from: before, to: target.value as? String)
458
+ if !flipped { Thread.sleep(forTimeInterval: 0.25) }
459
+ } while !flipped && Date() < settle
460
+ check(flipped, "tapid \(arg) flips the switch")
461
+ }
462
+ else { press(target); note("tapid \(arg): ok") }
406
463
  case "longpressid":
407
464
  let target = identified(arg)
408
465
  guard target.waitForExistence(timeout: 10), target.isHittable else {
@@ -590,13 +647,13 @@ final class DriverTests: XCTestCase {
590
647
  case "scrollto":
591
648
  var found = element(containing: arg)
592
649
  var drags = 0
593
- while (found == nil || !(found!.isHittable)) && drags < 12 {
650
+ while !scrollTargetVisible(found) && drags < 12 {
594
651
  let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
595
652
  scrollPage(towardTop: towardTop)
596
653
  drags += 1
597
654
  found = element(containing: arg)
598
655
  }
599
- check(found != nil && found!.isHittable, "scrollto \(arg)")
656
+ check(scrollTargetVisible(found), "scrollto \(arg)")
600
657
  case "swipe":
601
658
  switch arg {
602
659
  case "up": app.swipeUp()
@@ -644,7 +701,9 @@ final class DriverTests: XCTestCase {
644
701
  // A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
645
702
  let label = arg.isEmpty ? "Not Now" : arg
646
703
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
647
- let candidates = [app.buttons[label], springboard.buttons[label]]
704
+ // The app's own alert or sheet first (its button can share a
705
+ // label with the control behind it), then the app, then iOS.
706
+ let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
648
707
  let end = Date().addingTimeInterval(6)
649
708
  // A tap is not a dismissal. iOS 26 draws "Save Password?" as a
650
709
  // sheet that is in the tree, and hittable, while it still slides
@@ -655,7 +714,7 @@ final class DriverTests: XCTestCase {
655
714
  var tapped = false
656
715
  var gone = false
657
716
  while !gone && Date() < end {
658
- guard let button = candidates.first(where: { $0.exists && $0.isHittable }) else {
717
+ guard let button = candidates().first(where: { $0.exists && $0.isHittable }) else {
659
718
  if tapped { gone = true } else { Thread.sleep(forTimeInterval: 0.5) }
660
719
  continue
661
720
  }