@craft-native/ios 0.0.490 → 0.0.492

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.492",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -819,6 +819,8 @@ final class CraftChrome: ObservableObject {
819
819
  var image: String? = nil
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
+ /// A shared material capsule; nil keeps separate floating buttons.
823
+ var accessoryLayout: String? = nil
822
824
  var companions: [Tab] = []
823
825
  /// A tap opens these destinations in a compact menu instead of navigating.
824
826
  var menu: [TabMenuItem] = []
@@ -964,10 +966,11 @@ final class CraftChrome: ObservableObject {
964
966
  if let item, let path = item["path"] as? String,
965
967
  path.hasPrefix("/"), !path.hasPrefix("//"),
966
968
  let title = item["title"] as? String {
967
- next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
969
+ next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "bubble.left",
968
970
  badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
969
971
  accessibilityLabel: item["accessibilityLabel"] as? String,
970
- iconOnly: item["iconOnly"] as? Bool ?? false,
972
+ iconOnly: (item["iconOnly"] as? Bool ?? false) || ["vertical", "horizontal"].contains(item["layout"] as? String ?? ""),
973
+ accessoryLayout: (item["layout"] as? String).flatMap { ["vertical", "horizontal"].contains($0) ? $0 : nil },
971
974
  companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
972
975
  guard let companionPath = action["path"] as? String,
973
976
  companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
@@ -1241,9 +1241,14 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1241
1241
  private var synchronizingTabs = false
1242
1242
  private var accessoryModel: CraftChrome.Tab?
1243
1243
  private let accessoryButton = UIButton(type: .system)
1244
+ // UIKit retains its shelf's content view across removal animations. Never
1245
+ // reparent that same button into floating chrome: the shelf can take it
1246
+ // back after our layout, leaving a perfectly sized but empty capsule.
1247
+ private let shelfAccessoryButton = UIButton(type: .system)
1244
1248
  private var companionButtons: [UIButton] = []
1245
1249
  private var companionModels: [CraftChrome.Tab] = []
1246
1250
  private var companionGlasses: [UIVisualEffectView] = []
1251
+ private var accessorySeparators: [UIView] = []
1247
1252
  private let accessoryBadge = UILabel()
1248
1253
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1249
1254
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1309,6 +1314,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1309
1314
  CraftChromeFonts.apply(to: tabBar)
1310
1315
  accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1311
1316
  accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1317
+ shelfAccessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1318
+ shelfAccessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1312
1319
  chromeSubscription = CraftChrome.shared.objectWillChange.sink { [weak self] in
1313
1320
  guard let self, !self.chromeUpdatePending else { return }
1314
1321
  self.chromeUpdatePending = true
@@ -1474,6 +1481,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1474
1481
  let button = UIButton(type: .system)
1475
1482
  var configuration = UIButton.Configuration.plain()
1476
1483
  configuration.image = UIImage(systemName: action.symbol)
1484
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1477
1485
  button.configuration = configuration
1478
1486
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1479
1487
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
@@ -1506,16 +1514,32 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1506
1514
  for (index, action) in companionModels.enumerated() {
1507
1515
  companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1508
1516
  }
1517
+ let grouped = model?.accessoryLayout != nil
1518
+ for glass in companionGlasses { glass.isHidden = grouped }
1519
+ for button in companionButtons { button.tintColor = grouped ? .label : nil }
1520
+ accessoryButton.tintColor = grouped ? .label : nil
1521
+ for separator in accessorySeparators { separator.removeFromSuperview() }
1522
+ accessorySeparators = grouped ? companionButtons.map { _ in
1523
+ let separator = UIView()
1524
+ separator.backgroundColor = .separator
1525
+ separator.isUserInteractionEnabled = false
1526
+ separator.isAccessibilityElement = false
1527
+ view.addSubview(separator)
1528
+ return separator
1529
+ } : []
1509
1530
  if let model {
1510
1531
  var configuration = UIButton.Configuration.plain()
1511
1532
  configuration.image = UIImage(systemName: model.symbol)
1533
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1512
1534
  configuration.title = model.iconOnly ? nil : model.displayTitle
1513
1535
  configuration.subtitle = model.iconOnly ? nil : model.badge
1514
1536
  configuration.imagePadding = model.iconOnly ? 0 : 8
1515
- accessoryButton.configuration = configuration
1516
- accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1517
- accessoryButton.accessibilityValue = model.badge
1518
- accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1537
+ for button in [accessoryButton, shelfAccessoryButton] {
1538
+ button.configuration = configuration
1539
+ button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1540
+ button.accessibilityValue = model.badge
1541
+ button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1542
+ }
1519
1543
  }
1520
1544
  #if compiler(>=6.2)
1521
1545
  if #available(iOS 26.0, *), model?.iconOnly != true {
@@ -1525,7 +1549,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1525
1549
  accessoryButton.removeFromSuperview()
1526
1550
  }
1527
1551
  if (model == nil) != (bottomAccessory == nil) {
1528
- bottomAccessory = model.map { _ in UITabAccessory(contentView: accessoryButton) }
1552
+ bottomAccessory = model.map { _ in UITabAccessory(contentView: shelfAccessoryButton) }
1529
1553
  }
1530
1554
  accessoryModel = model
1531
1555
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = 0 }
@@ -1549,7 +1573,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1549
1573
  accessoryButton.isHidden = model == nil
1550
1574
  accessoryGlass.isHidden = model == nil
1551
1575
  accessoryGlass.isUserInteractionEnabled = false
1552
- accessoryGlass.layer.cornerRadius = 24
1576
+ accessoryGlass.layer.cornerRadius = grouped ? 26 : 24
1553
1577
  accessoryGlass.clipsToBounds = true
1554
1578
  accessoryButton.backgroundColor = .clear
1555
1579
  accessoryButton.layer.cornerRadius = 24
@@ -1563,6 +1587,15 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1563
1587
  accessoryBadge.clipsToBounds = true
1564
1588
  accessoryBadge.isAccessibilityElement = false
1565
1589
  accessoryModel = model
1590
+ // The shared material must stay behind every button, even after a
1591
+ // companion-only update creates new controls or a shelf changes mode.
1592
+ if accessoryGlass.superview === view {
1593
+ view.bringSubviewToFront(accessoryGlass)
1594
+ for button in companionButtons { view.bringSubviewToFront(button) }
1595
+ view.bringSubviewToFront(accessoryButton)
1596
+ for separator in accessorySeparators { view.bringSubviewToFront(separator) }
1597
+ view.bringSubviewToFront(accessoryBadge)
1598
+ }
1566
1599
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
1567
1600
  view.setNeedsLayout()
1568
1601
  }
@@ -1586,14 +1619,30 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1586
1619
  let iconOnly = accessoryModel?.iconOnly == true
1587
1620
  let height: CGFloat = iconOnly ? 48 : 44
1588
1621
  let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
1589
- let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1622
+ let trailingX = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1623
+ ? view.safeAreaInsets.left + 20 : view.bounds.maxX - view.safeAreaInsets.right - width - 20
1624
+ let x = iconOnly ? trailingX : 16
1590
1625
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1591
1626
  accessoryGlass.frame = accessoryButton.frame
1627
+ let layout = accessoryModel?.accessoryLayout
1592
1628
  for (index, button) in companionButtons.enumerated() {
1593
- // Array order reads left to right, ending at the primary action.
1594
- button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
1629
+ let offset = CGFloat(companionButtons.count - index)
1630
+ if layout == "vertical" {
1631
+ button.frame = accessoryButton.frame.offsetBy(dx: 0, dy: -offset * height)
1632
+ } else {
1633
+ let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
1634
+ button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + (layout == nil ? 12 : 0)), dy: 0)
1635
+ }
1595
1636
  companionGlasses[index].frame = button.frame
1637
+ if layout != nil {
1638
+ accessoryGlass.frame = accessoryGlass.frame.union(button.frame)
1639
+ let line = 1 / (view.window?.screen.scale ?? 2)
1640
+ accessorySeparators[index].frame = layout == "vertical"
1641
+ ? CGRect(x: button.frame.minX + 12, y: button.frame.maxY - line / 2, width: width - 24, height: line)
1642
+ : CGRect(x: view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? button.frame.minX : button.frame.maxX - line / 2, y: button.frame.minY + 12, width: line, height: height - 24)
1643
+ }
1596
1644
  }
1645
+ if layout != nil { accessoryGlass.frame = accessoryGlass.frame.insetBy(dx: -2, dy: -2) }
1597
1646
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1598
1647
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1599
1648
  // A compact action floats over the page, not across its whole width.
@@ -2595,8 +2644,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
2595
2644
  // Craft's content swipe must not steal their drag/hold gestures.
2596
2645
  let point = gestureRecognizer.location(in: view)
2597
2646
  if !tabBar.isHidden, tabBar.frame.contains(point) { return false }
2598
- if !accessoryButton.isHidden, accessoryButton.window != nil,
2599
- accessoryButton.convert(accessoryButton.bounds, to: view).contains(point) { return false }
2647
+ if [accessoryButton, shelfAccessoryButton].contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2648
+ if companionButtons.contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2600
2649
  // A swipe may begin while the last one settles: it catches it.
2601
2650
  guard activeTabSwipeTarget == nil || tabSwipeSettling,
2602
2651
  hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
@@ -2007,8 +2007,9 @@ enum CraftSnapshots {
2007
2007
  // after a failure finds the previous value, because `pending`
2008
2008
  // was cleared above and the cache was only refilled on success.
2009
2009
  // Posting only on success left a failed checkpoint on screen as
2010
- // though it had been kept; Android already restores it
2011
- // (`changed("snapshots", name, if (ok) json else previous, null)`).
2010
+ // though it had been kept; Android restores it by sending what
2011
+ // a read gives once the write settles
2012
+ // (`changed("snapshots", name, current, null)`).
2012
2013
  NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
2013
2014
  completion?(ok)
2014
2015
  }
@@ -126,12 +126,21 @@ enum CraftNativeFiles {
126
126
  return url
127
127
  }
128
128
 
129
- /// A file name with no path in it.
129
+ /// A file name with no path in it that the disk will take. A long name
130
+ /// is cut to fit and keeps its extension, which is how a share sheet and
131
+ /// an upload know what the file is.
130
132
  static func safeName(_ name: String) -> String {
131
133
  let parts = name.components(separatedBy: CharacterSet(charactersIn: "/\\:\u{0}\r\n"))
132
134
  var cleaned = parts.joined(separator: "-").trimmingCharacters(in: .whitespacesAndNewlines)
133
135
  while cleaned.hasPrefix(".") { cleaned.removeFirst() }
134
- return cleaned.isEmpty ? "file" : String(cleaned.prefix(120))
136
+ // 255 UTF-16 units is what the file system takes, and 120 characters
137
+ // of flags or family emoji is more than that.
138
+ guard cleaned.count > 120 || cleaned.utf16.count > 255 else { return cleaned.isEmpty ? "file" : cleaned }
139
+ let ending = (cleaned as NSString).pathExtension
140
+ let suffix = ending.isEmpty || ending.count > 15 ? "" : "." + ending
141
+ var stem = String(cleaned.dropLast(suffix.count).prefix(120 - suffix.count))
142
+ while (stem + suffix).utf16.count > 255, !stem.isEmpty { stem.removeLast() }
143
+ return stem + suffix
135
144
  }
136
145
 
137
146
  static func describe(_ url: URL, type: String, extra: [String: Any] = [:]) -> [String: Any] {
@@ -857,6 +866,30 @@ enum CraftNativeFiles {
857
866
  }
858
867
  }
859
868
 
869
+ /// Base64 as `atob` reads it, which is what the browser host runs and
870
+ /// what Android is held to: the alphabet, ASCII white space anywhere,
871
+ /// padding optional but right when it is there, and nothing else.
872
+ /// Foundation alone wants the padding present and lets a third `=` by.
873
+ static func decodeBase64(_ text: String) -> Data? {
874
+ var data = [UInt8]()
875
+ data.reserveCapacity(text.utf8.count + 2)
876
+ var padding = 0
877
+ for byte in text.utf8 {
878
+ switch byte {
879
+ case 0x20, 0x09, 0x0A, 0x0C, 0x0D: continue
880
+ case UInt8(ascii: "="): padding += 1
881
+ case _ where padding > 0: return nil
882
+ case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"),
883
+ UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "+"), UInt8(ascii: "/"):
884
+ data.append(byte)
885
+ default: return nil
886
+ }
887
+ }
888
+ guard padding <= 2, data.count % 4 != 1, padding == 0 || (data.count + padding) % 4 == 0 else { return nil }
889
+ data.append(contentsOf: repeatElement(UInt8(ascii: "="), count: (4 - data.count % 4) % 4))
890
+ return Data(base64Encoded: Data(data))
891
+ }
892
+
860
893
  /// What a screen made itself (a CSV export, a route it built) written as
861
894
  /// a file for the share sheet: `data` is text (`encoding: 'utf8'`, the
862
895
  /// default, so a CSV crosses the bridge once and no larger) or base64
@@ -874,7 +907,7 @@ enum CraftNativeFiles {
874
907
  case "utf8":
875
908
  bytes = Data(text.utf8)
876
909
  case "base64":
877
- guard let decoded = Data(base64Encoded: text.filter { !$0.isWhitespace }) else {
910
+ guard let decoded = decodeBase64(text) else {
878
911
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write was given base64 it could not read"))
879
912
  }
880
913
  bytes = decoded
@@ -9423,7 +9423,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9423
9423
  case ("Audio", "resume"):
9424
9424
  reply(.success(sounds.resume(id: id)))
9425
9425
  case ("Audio", "setVolume"):
9426
- reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
9426
+ // A missing or non-numeric volume used to arrive here as 0 — the
9427
+ // `number` helper's fallback — which silenced the sound and
9428
+ // answered true. Android refuses it and leaves the sound as it
9429
+ // was (`!volume.isFinite() || !fade.isFinite() || fade < 0`), so a
9430
+ // malformed call muted on one host and did nothing on the other.
9431
+ let fade = number(2, "fade")
9432
+ guard args.count > 1, let volume = craftNativeNumber(args[1])?.doubleValue,
9433
+ volume.isFinite, fade.isFinite, fade >= 0 else {
9434
+ reply(.success(false))
9435
+ return true
9436
+ }
9437
+ reply(.success(sounds.setVolume(id: id, volume: volume, fade: fade / 1000)))
9427
9438
  case ("Audio", "stop"):
9428
9439
  reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
9429
9440
  case ("Audio", "stopAll"):
@@ -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
 
@@ -342,7 +342,19 @@ final class DriverTests: XCTestCase {
342
342
  }
343
343
 
344
344
  /// Deliver one gesture; the command's observation determines success.
345
- func press(_ element: XCUIElement) {
345
+ ///
346
+ /// Waits for the target to come to rest first (`settledFrame`) and returns
347
+ /// the frame it settled on. Every tap command goes through here, so a
348
+ /// control touched straight after a flick cannot be hit while the scroll
349
+ /// view is still decelerating — whichever command did the tapping. #449
350
+ /// settled only the boolean `tapid` branch, which left `tap`, `tapif`,
351
+ /// `tapexact` and non-boolean `tapid` with the same exposure.
352
+ ///
353
+ /// Waiting before the one gesture is not a second gesture, and for a
354
+ /// control already at rest this costs one 0.1s read.
355
+ @discardableResult
356
+ func press(_ element: XCUIElement) -> CGRect {
357
+ let settled = settledFrame(element)
346
358
  // Resolve this before activation: a menu item can disappear as soon
347
359
  // as its gesture completes (for example Select All).
348
360
  let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
@@ -352,6 +364,31 @@ final class DriverTests: XCTestCase {
352
364
  // A tap on a read-only text view need not open the keyboard. Only a
353
365
  // following type command observes whether this target took focus.
354
366
  lastTappedInput = input
367
+ return settled
368
+ }
369
+
370
+ /// `target`'s frame once it has stopped moving, or its last reading at
371
+ /// the deadline. This only ever waits — it never taps.
372
+ ///
373
+ /// A journey that flicks the page before touching a control leaves the
374
+ /// scroll view decelerating, and a touch that lands on a decelerating
375
+ /// scroll view stops the scroll instead of reaching the control. HQ's
376
+ /// agreement journey does exactly that: `swipe up`, then `tapid` the
377
+ /// first switch, which stayed off while the second one — tapped seconds
378
+ /// later — turned on (#449). `scrollto` and `drag` both end with a hold
379
+ /// that leaves the content at rest; `swipe` is a flick and does not.
380
+ ///
381
+ /// Waiting before the one gesture is not a second gesture.
382
+ func settledFrame(_ target: XCUIElement, within seconds: TimeInterval = 1.0) -> CGRect {
383
+ var frame = target.frame
384
+ let deadline = Date().addingTimeInterval(seconds)
385
+ while Date() < deadline {
386
+ Thread.sleep(forTimeInterval: 0.1)
387
+ let next = target.frame
388
+ if next.equalTo(frame) { return frame }
389
+ frame = next
390
+ }
391
+ return frame
355
392
  }
356
393
 
357
394
  func answerOpenPrompt() {
@@ -517,19 +554,26 @@ final class DriverTests: XCTestCase {
517
554
  // renderer or hit-testing failure (HQ agreement job 7570).
518
555
  let before = target.value as? String
519
556
  let enabledBefore = target.isEnabled
520
- let frameBefore = target.frame
521
- press(target)
557
+ // press settles before it taps and hands back the frame it
558
+ // settled on, so this is one read rather than two.
559
+ let frameBefore = press(target)
522
560
  let settle = Date().addingTimeInterval(1.5)
523
561
  var flipped = false
524
562
  repeat {
525
563
  flipped = switchFlipped(from: before, to: target.value as? String)
526
564
  if !flipped { Thread.sleep(forTimeInterval: 0.25) }
527
565
  } while !flipped && Date() < settle
566
+ let frameAfter = target.frame
528
567
  let observation: [String: Any] = [
529
568
  "id": arg, "gestures": 1, "flipped": flipped,
530
569
  "enabledBefore": enabledBefore, "enabledAfter": target.isEnabled,
531
570
  "valueBefore": before ?? "unknown", "valueAfter": target.value as? String ?? "unknown",
532
571
  "frameBefore": [frameBefore.origin.x, frameBefore.origin.y, frameBefore.width, frameBefore.height],
572
+ // Without this a control that was still moving when it
573
+ // was tapped looks exactly like one that sat still and
574
+ // ignored the tap, which is where #449 ran out of
575
+ // evidence.
576
+ "frameAfter": [frameAfter.origin.x, frameAfter.origin.y, frameAfter.width, frameAfter.height],
533
577
  ]
534
578
  lastBooleanTap = observation
535
579
  if let data = try? JSONSerialization.data(withJSONObject: observation, options: [.sortedKeys]),