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.
- package/dist/android/templates/CraftNativeCapabilities.kt.template +15 -3
- package/dist/android/templates/CraftNativeSounds.kt.template +3 -2
- package/dist/android/templates/MainActivityNative.kt.template +61 -12
- package/dist/browser.js +9 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +2 -1
- package/dist/ios/templates/CraftApp.swift +63 -0
- package/dist/ios/templates/CraftNativeMutation.swift +18 -3
- package/dist/ios/templates/CraftNativeScreen.swift +82 -39
- package/dist/ios/templates/CraftVoiceRecording.swift +62 -5
- package/dist/ios/templates/testing/CraftTimedInput.m +90 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +73 -14
- package/dist/macos/src/index.js +2 -2
- package/dist/macos/src/toolchain.d.ts +2 -2
- package/dist/macos/templates/CraftMacApplication.swift +23 -5
- package/dist/macos/templates/CraftMacRenderer.swift +85 -22
- package/dist/macos/templates/CraftMacRuntime.swift +34 -2
- package/dist/macos/templates/CraftNativeMutation.swift +18 -3
- package/package.json +1 -1
|
@@ -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:
|
|
4170
|
+
class CraftNativePositionedImageView: UIView {
|
|
4169
4171
|
private var storedImage: UIImage?
|
|
4170
|
-
private
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
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!
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 ||
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
11027
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11389
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|