craft-native 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.
@@ -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
  }