@craft-native/ios 0.0.490 → 0.0.491

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.490",
3
+ "version": "0.0.491",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -324,11 +324,19 @@ final class CraftNativeSounds {
324
324
  object: nil,
325
325
  queue: .main,
326
326
  ) { note in CraftNativeSounds.handleInterruption(note) }
327
+ /// There is no interruption for the media server restarting, and it is
328
+ /// not recoverable the way an interruption is: see `handleMediaReset`.
329
+ private static let mediaResetObserver = NotificationCenter.default.addObserver(
330
+ forName: AVAudioSession.mediaServicesWereResetNotification,
331
+ object: nil,
332
+ queue: .main,
333
+ ) { _ in CraftNativeSounds.handleMediaReset() }
327
334
  /// `{ id, event: "ended" | "error", message? }` to the screen's script.
328
335
  var emit: (([String: Any]) -> Void)?
329
336
 
330
337
  init() {
331
338
  _ = Self.interruptionObserver
339
+ _ = Self.mediaResetObserver
332
340
  Self.services.add(self)
333
341
  }
334
342
 
@@ -362,6 +370,33 @@ final class CraftNativeSounds {
362
370
  }
363
371
  }
364
372
 
373
+ /// The media server restarted, which orphans every audio object the
374
+ /// process holds. Apple's requirement is to discard them and make new
375
+ /// ones; an orphaned `AVQueuePlayer` never plays again, never reaches the
376
+ /// end of its item and so never reports anything. Without this, a sound
377
+ /// playing across a reset left the script a handle that was silent
378
+ /// forever, `pause`/`resume` answering `true` about nothing, and the
379
+ /// session claim never released — while Android reports the equivalent
380
+ /// permanent loss of audio focus as an `error` event and drops the sound.
381
+ ///
382
+ /// A file player's playhead does not survive the reset, so the handle is
383
+ /// released and the loss reported rather than guessed at. Web Audio is
384
+ /// recovered instead (CraftWebAudio.swift): its graph is pure synthesis
385
+ /// the script is still driving, so a new engine continues it.
386
+ static let mediaResetMessage = "Audio stopped because the media services were reset"
387
+
388
+ /// Sound services and their playback controls are used on main.
389
+ private static func handleMediaReset() {
390
+ // The pre-reset interruption, if any, is gone with the server.
391
+ sessionInterrupted = false
392
+ for service in services.allObjects {
393
+ for id in Array(service.sounds.keys) {
394
+ service.remove(id, result: .failure(CraftNativeActionError(code: "AUDIO_ERROR", message: mediaResetMessage)))
395
+ service.emit?(["id": id, "event": "error", "message": mediaResetMessage])
396
+ }
397
+ }
398
+ }
399
+
365
400
  /// Starts `url`. `answer` hears once it plays (or could not be loaded).
366
401
  func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
367
402
  stop(id: id, fadeOut: 0)
@@ -15,7 +15,12 @@ extension AVAudioRecorder: CraftVoiceRecorder {}
15
15
  /// is starting, so a late permission/encoder answer cannot resurrect it.
16
16
  final class CraftVoiceRecording {
17
17
  static let shared = CraftVoiceRecording()
18
- private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .utility)
18
+ /// Deleting a recording somebody just cancelled is work they are waiting
19
+ /// on: until it is gone, what they decided not to send still exists. At
20
+ /// `.utility` a busy device could leave the removal queued for seconds
21
+ /// behind everything else (it missed a two-second bound on a loaded CI
22
+ /// simulator while every other step of the cancellation was on time).
23
+ private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .userInitiated)
19
24
  private static let launchCleanup: Void = discardAbandonedRecordings(includeLegacy: false)
20
25
  private static let legacyCleanup: Void = discardAbandonedRecordings()
21
26
  private static let folderName = "voice-recordings"
@@ -106,7 +111,16 @@ final class CraftVoiceRecording {
106
111
  private let startupTimeout: TimeInterval
107
112
  private let stopTimeout: TimeInterval
108
113
  struct FileRemovalReceipt: Encodable {
114
+ /// From deciding to delete to the block actually running: the trace,
115
+ /// the submission, and the wait for a thread.
109
116
  let queueMilliseconds: Double
117
+ /// The first of those three alone. A run where `queueMilliseconds` is
118
+ /// seconds and this is near zero spent them waiting for GCD to admit
119
+ /// the block; one where this carries most of them spent them in
120
+ /// `trace`, which takes a process-wide lock and then logs
121
+ /// synchronously. Without it the two are indistinguishable, which is
122
+ /// where #490 ran out of evidence at 7638ms of queue for 3ms of work.
123
+ let submitMilliseconds: Double
110
124
  let removalMilliseconds: Double
111
125
  let ownedBefore: Bool
112
126
  let removed: Bool
@@ -178,12 +192,14 @@ final class CraftVoiceRecording {
178
192
  let queued = ProcessInfo.processInfo.systemUptime
179
193
  let observed = fileRemovalObserved
180
194
  Self.trace("cancel \(session.key.prefix(8)): file removal queued")
195
+ let submitted = ProcessInfo.processInfo.systemUptime
181
196
  Self.fileQueue.async {
182
197
  let began = ProcessInfo.processInfo.systemUptime
183
198
  let owned = CraftNativeFiles.owned(file.path) != nil
184
199
  let removed = CraftNativeFiles.remove(file.path)
185
200
  let receipt = FileRemovalReceipt(
186
201
  queueMilliseconds: (began - queued) * 1000,
202
+ submitMilliseconds: (submitted - queued) * 1000,
187
203
  removalMilliseconds: (ProcessInfo.processInfo.systemUptime - began) * 1000,
188
204
  ownedBefore: owned, removed: removed,
189
205
  existsAfter: FileManager.default.fileExists(atPath: file.path))
@@ -1361,7 +1361,12 @@ final class CraftWebAudio {
1361
1361
  private var engine: AVAudioEngine?
1362
1362
  private var claimedSession = false
1363
1363
  private var pump: Timer?
1364
+ /// Notifications about the audio session and the app, registered once and
1365
+ /// outliving any one engine.
1364
1366
  private var observers: [NSObjectProtocol] = []
1367
+ /// And the one about this engine's configuration, which does not: a
1368
+ /// rebuilt engine is a different object (`rebuildEngine`).
1369
+ private var engineObserver: NSObjectProtocol?
1365
1370
  private weak var script: JSContext?
1366
1371
 
1367
1372
  init(live: Bool = true) {
@@ -1434,15 +1439,7 @@ final class CraftWebAudio {
1434
1439
  contexts[id] = nil
1435
1440
  return nil
1436
1441
  }
1437
- let engine = self.engine ?? makeEngine()
1438
- let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
1439
- let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
1440
- silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
1441
- return noErr
1442
- }
1443
- engine.attach(source)
1444
- engine.connect(source, to: engine.mainMixerNode, format: format)
1445
- context.source = source
1442
+ attachSource(context, format: format, to: self.engine ?? makeEngine())
1446
1443
  refreshEngine()
1447
1444
  }
1448
1445
  startPump()
@@ -1482,25 +1479,84 @@ final class CraftWebAudio {
1482
1479
 
1483
1480
  // MARK: Engine and session
1484
1481
 
1482
+ /// A context's graph reaches the engine through a source node. Attached
1483
+ /// when the context opens, and again on a fresh engine for every live
1484
+ /// context after the media server restarts.
1485
+ private func attachSource(_ context: Context, format: AVAudioFormat, to engine: AVAudioEngine) {
1486
+ let graph = context.graph
1487
+ let source = AVAudioSourceNode(format: format) { silence, _, frameCount, bufferList in
1488
+ let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
1489
+ silence.pointee = ObjCBool(graph.render(Int(frameCount), into: buffers))
1490
+ return noErr
1491
+ }
1492
+ engine.attach(source)
1493
+ engine.connect(source, to: engine.mainMixerNode, format: format)
1494
+ context.source = source
1495
+ }
1496
+
1485
1497
  private func makeEngine() -> AVAudioEngine {
1486
1498
  let engine = AVAudioEngine()
1487
1499
  self.engine = engine
1488
1500
  let center = NotificationCenter.default
1489
1501
  // A route change (headphones) or an interruption (a call) stops the
1490
- // engine; it starts again when the context still plays.
1491
- observers.append(center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
1502
+ // engine; it starts again when the context still plays. This one is
1503
+ // scoped to the engine, so a rebuilt engine replaces it.
1504
+ if let engineObserver { center.removeObserver(engineObserver) }
1505
+ engineObserver = center.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in
1492
1506
  self?.refreshEngine()
1493
- })
1507
+ }
1508
+ // The rest are about the session and the app, not this engine.
1509
+ guard observers.isEmpty else { return engine }
1494
1510
  observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
1495
1511
  let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
1496
1512
  if type == AVAudioSession.InterruptionType.ended.rawValue { self?.refreshEngine() }
1497
1513
  })
1514
+ observers.append(center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
1515
+ self?.rebuildEngine()
1516
+ })
1498
1517
  observers.append(center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { [weak self] _ in
1499
1518
  self?.refreshEngine()
1500
1519
  })
1501
1520
  return engine
1502
1521
  }
1503
1522
 
1523
+ /// The media server restarted, which orphans every audio object the
1524
+ /// process holds — this engine and the source nodes attached to it. An
1525
+ /// orphaned engine can be told to start and still produce no sound, and
1526
+ /// no configuration-change notification is posted for it, so
1527
+ /// `refreshEngine` cannot recover from this. Apple's requirement is to
1528
+ /// discard the orphans and make new ones.
1529
+ ///
1530
+ /// A context does survive, because `CraftAudioGraph` is pure synthesis
1531
+ /// holding no AVFoundation object: it is given a fresh source node on a
1532
+ /// fresh engine and keeps playing from where its own clock is. That is
1533
+ /// why this recovers where a file-backed sound reports the loss instead
1534
+ /// (`CraftNativeSounds.handleMediaReset`) — a player's playhead does not
1535
+ /// survive the reset, a synthesised graph does. Starting the new engine
1536
+ /// reactivates the session, so the claim this screen already holds does
1537
+ /// not need to be taken again.
1538
+ private func rebuildEngine() {
1539
+ guard live, let orphaned = engine else { return }
1540
+ for context in contexts.values {
1541
+ if let source = context.source {
1542
+ orphaned.disconnectNodeOutput(source)
1543
+ orphaned.detach(source)
1544
+ }
1545
+ context.source = nil
1546
+ }
1547
+ orphaned.stop()
1548
+ if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
1549
+ engineObserver = nil
1550
+ engine = nil
1551
+ guard !contexts.isEmpty else { return }
1552
+ let engine = makeEngine()
1553
+ for context in contexts.values {
1554
+ guard let format = AVAudioFormat(standardFormatWithSampleRate: context.graph.sampleRate, channels: 2) else { continue }
1555
+ attachSource(context, format: format, to: engine)
1556
+ }
1557
+ refreshEngine()
1558
+ }
1559
+
1504
1560
  private func refreshEngine() {
1505
1561
  guard live, let engine else { return }
1506
1562
  let playing = contexts.values.contains { $0.running }
@@ -1522,6 +1578,8 @@ final class CraftWebAudio {
1522
1578
  }
1523
1579
 
1524
1580
  private func stopEngine() {
1581
+ if let engineObserver { NotificationCenter.default.removeObserver(engineObserver) }
1582
+ engineObserver = nil
1525
1583
  observers.forEach(NotificationCenter.default.removeObserver)
1526
1584
  observers.removeAll()
1527
1585
  engine?.stop()
@@ -1714,6 +1772,13 @@ final class CraftWebAudio {
1714
1772
  }
1715
1773
 
1716
1774
  var contextIdsForTesting: [Int] { Array(contexts.keys).sorted() }
1775
+ /// The engine and a context's source node, so a test can see that a
1776
+ /// media-services reset replaced them rather than restarting orphans
1777
+ /// (`rebuildEngine`). Posting the notification cannot really orphan them,
1778
+ /// so identity is the only observable; the clock moving afterwards is
1779
+ /// what shows the replacements actually render.
1780
+ var engineForTesting: AVAudioEngine? { engine }
1781
+ func sourceForTesting(_ contextId: Int) -> AVAudioSourceNode? { contexts[contextId]?.source }
1717
1782
 
1718
1783
  // MARK: Helpers
1719
1784
 
@@ -354,6 +354,30 @@ final class DriverTests: XCTestCase {
354
354
  lastTappedInput = input
355
355
  }
356
356
 
357
+ /// `target`'s frame once it has stopped moving, or its last reading at
358
+ /// the deadline. This only ever waits — it never taps.
359
+ ///
360
+ /// A journey that flicks the page before touching a control leaves the
361
+ /// scroll view decelerating, and a touch that lands on a decelerating
362
+ /// scroll view stops the scroll instead of reaching the control. HQ's
363
+ /// agreement journey does exactly that: `swipe up`, then `tapid` the
364
+ /// first switch, which stayed off while the second one — tapped seconds
365
+ /// later — turned on (#449). `scrollto` and `drag` both end with a hold
366
+ /// that leaves the content at rest; `swipe` is a flick and does not.
367
+ ///
368
+ /// Waiting before the one gesture is not a second gesture.
369
+ func settledFrame(_ target: XCUIElement, within seconds: TimeInterval = 1.0) -> CGRect {
370
+ var frame = target.frame
371
+ let deadline = Date().addingTimeInterval(seconds)
372
+ while Date() < deadline {
373
+ Thread.sleep(forTimeInterval: 0.1)
374
+ let next = target.frame
375
+ if next.equalTo(frame) { return frame }
376
+ frame = next
377
+ }
378
+ return frame
379
+ }
380
+
357
381
  func answerOpenPrompt() {
358
382
  // A direct app link has already arrived. Querying an invisible
359
383
  // SpringBoard here can stall its accessibility snapshot on iOS 27.
@@ -517,7 +541,7 @@ final class DriverTests: XCTestCase {
517
541
  // renderer or hit-testing failure (HQ agreement job 7570).
518
542
  let before = target.value as? String
519
543
  let enabledBefore = target.isEnabled
520
- let frameBefore = target.frame
544
+ let frameBefore = settledFrame(target)
521
545
  press(target)
522
546
  let settle = Date().addingTimeInterval(1.5)
523
547
  var flipped = false
@@ -525,11 +549,17 @@ final class DriverTests: XCTestCase {
525
549
  flipped = switchFlipped(from: before, to: target.value as? String)
526
550
  if !flipped { Thread.sleep(forTimeInterval: 0.25) }
527
551
  } while !flipped && Date() < settle
552
+ let frameAfter = target.frame
528
553
  let observation: [String: Any] = [
529
554
  "id": arg, "gestures": 1, "flipped": flipped,
530
555
  "enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
531
556
  "valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
532
557
  "frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
558
+ // Without this a control that was still moving when it
559
+ // was tapped looks exactly like one that sat still and
560
+ // ignored the tap, which is where #449 ran out of
561
+ // evidence.
562
+ "frameAfter": [frameAfter.origin.x, frameAfter.origin.y, frameAfter.width, frameAfter.height],
533
563
  ]
534
564
  lastBooleanTap = observation
535
565
  if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),