@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 +1 -1
- package/templates/CraftApp.swift +5 -2
- package/templates/CraftHybrid.swift +60 -11
- package/templates/CraftNativeActions.swift +3 -2
- package/templates/CraftNativeFiles.swift +36 -3
- package/templates/CraftNativeScreen.swift +12 -1
- package/templates/CraftNativeServices.swift +35 -0
- package/templates/CraftVoiceRecording.swift +17 -1
- package/templates/CraftWebAudio.swift +77 -12
- package/templates/testing/DriverTests.swift.template +47 -3
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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 ?? "
|
|
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
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1594
|
-
|
|
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
|
|
2599
|
-
|
|
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
|
|
2011
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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]),
|