craft-native 0.0.341 → 0.0.345
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 +4 -1
- package/dist/android/templates/CraftNativeServices.kt.template +35 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +8 -2
- package/dist/ios/templates/CraftNativeFiles.swift +3 -0
- package/dist/ios/templates/CraftNativeScreen.swift +1 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +185 -37
- package/dist/ios/templates/testing/DriverTests.swift.template +8 -2
- package/package.json +1 -1
|
@@ -630,7 +630,10 @@ internal class CraftNativeCapabilities(
|
|
|
630
630
|
private fun sweepFiles() {
|
|
631
631
|
val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
|
|
632
632
|
fileRoot.listFiles()?.forEach { file ->
|
|
633
|
-
|
|
633
|
+
// Voice captures have a separate launch lifetime; sweep only
|
|
634
|
+
// generic per-file UUID folders here.
|
|
635
|
+
val owned = runCatching { UUID.fromString(file.name).toString().equals(file.name, ignoreCase = true) }.getOrDefault(false)
|
|
636
|
+
if (owned && file.lastModified() < cutoff) file.deleteRecursively()
|
|
634
637
|
}
|
|
635
638
|
}
|
|
636
639
|
|
|
@@ -85,6 +85,7 @@ internal class CraftNativeServices(
|
|
|
85
85
|
{{NATIVE_WALLET_FIELDS}}
|
|
86
86
|
|
|
87
87
|
init {
|
|
88
|
+
prepareVoiceFiles(context, config.optBoolean("enableAudioRecording"))
|
|
88
89
|
if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
|
|
89
90
|
// Native screens must also hear a recording started by the page.
|
|
90
91
|
// Keep this process sink independent from the page's listener.
|
|
@@ -629,7 +630,7 @@ internal class CraftNativeServices(
|
|
|
629
630
|
private var voiceFile: File? = null
|
|
630
631
|
private var pendingVoicePermission: ((Boolean) -> Unit)? = null
|
|
631
632
|
|
|
632
|
-
fun pauseVoice() { if (voiceRecorder != null) cancelVoice() }
|
|
633
|
+
fun pauseVoice() { if (voiceRecorder != null || voiceFile != null) cancelVoice() }
|
|
633
634
|
private fun cancelVoice() {
|
|
634
635
|
voiceOwner = null
|
|
635
636
|
val recorder = voiceRecorder
|
|
@@ -676,7 +677,7 @@ internal class CraftNativeServices(
|
|
|
676
677
|
if (voiceOwner != key) { done(failure("RECORDING_CANCELLED", "Recording was cancelled")); return@start }
|
|
677
678
|
if (!allowed) { cancelVoice(); done(failure("PERMISSION_DENIED", "Microphone permission was refused")); return@start }
|
|
678
679
|
try {
|
|
679
|
-
val folder = File(context.cacheDir, "craft-files/" + UUID.randomUUID()).apply { mkdirs() }
|
|
680
|
+
val folder = File(context.cacheDir, "craft-files/voice-recordings/" + UUID.randomUUID()).apply { mkdirs() }
|
|
680
681
|
val file = File(folder, "voice-feedback.m4a")
|
|
681
682
|
voiceFile = file
|
|
682
683
|
@Suppress("DEPRECATION")
|
|
@@ -816,6 +817,38 @@ internal class CraftNativeServices(
|
|
|
816
817
|
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
817
818
|
|
|
818
819
|
companion object {
|
|
820
|
+
private var voiceFilesPrepared = false
|
|
821
|
+
private var legacyVoiceFilesPrepared = false
|
|
822
|
+
|
|
823
|
+
@Synchronized
|
|
824
|
+
private fun prepareVoiceFiles(context: Context, includeLegacy: Boolean) {
|
|
825
|
+
val root = File(context.cacheDir, "craft-files")
|
|
826
|
+
if (!voiceFilesPrepared) {
|
|
827
|
+
discardAbandonedVoiceFiles(root, false)
|
|
828
|
+
voiceFilesPrepared = true
|
|
829
|
+
}
|
|
830
|
+
if (includeLegacy && !legacyVoiceFilesPrepared) {
|
|
831
|
+
discardAbandonedVoiceFiles(root)
|
|
832
|
+
legacyVoiceFilesPrepared = true
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
internal fun discardAbandonedVoiceFiles(root: File, includeLegacy: Boolean = true) {
|
|
837
|
+
val base = root.canonicalFile
|
|
838
|
+
val voice = File(base, "voice-recordings")
|
|
839
|
+
for (scope in if (includeLegacy) listOf(base, voice) else listOf(voice)) {
|
|
840
|
+
if (scope.canonicalFile != scope.absoluteFile) continue
|
|
841
|
+
for (folder in scope.listFiles().orEmpty()) {
|
|
842
|
+
val owned = runCatching { UUID.fromString(folder.name).toString().equals(folder.name, ignoreCase = true) }.getOrDefault(false)
|
|
843
|
+
if (!owned || folder.canonicalFile != folder.absoluteFile) continue
|
|
844
|
+
val file = File(folder, "voice-feedback.m4a")
|
|
845
|
+
if (!file.isFile || file.canonicalFile != file.absoluteFile) continue
|
|
846
|
+
file.delete()
|
|
847
|
+
if (folder.listFiles()?.isEmpty() == true) folder.delete()
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
819
852
|
const val PUSH_PERMISSION_REQUEST = 4207
|
|
820
853
|
const val LOCATION_PERMISSION_REQUEST = 4208
|
|
821
854
|
}
|
package/dist/cli.js
CHANGED
|
@@ -3022,7 +3022,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3022
3022
|
`);
|
|
3023
3023
|
}
|
|
3024
3024
|
// package.json
|
|
3025
|
-
var version = "0.0.
|
|
3025
|
+
var version = "0.0.345";
|
|
3026
3026
|
|
|
3027
3027
|
// bin/cli.ts
|
|
3028
3028
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -1796,12 +1796,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1796
1796
|
hybrid.openOutside(raw)
|
|
1797
1797
|
return
|
|
1798
1798
|
}
|
|
1799
|
-
open(CraftHybridRoutes.appPath(of: url))
|
|
1799
|
+
open(CraftHybridRoutes.appPath(of: url), props: props)
|
|
1800
1800
|
return
|
|
1801
1801
|
}
|
|
1802
1802
|
guard let path = CraftHybridRoutes.normalize(raw) else { return }
|
|
1803
1803
|
if let tab = hybrid.tabIds.first(where: { CraftHybridRoutes.normalize($0) == path }) {
|
|
1804
|
-
|
|
1804
|
+
if hybrid.routes.match(raw) != nil, URLComponents(string: raw)?.query != nil || !props.isEmpty {
|
|
1805
|
+
// A checkout/auth return targets the retained tab with new
|
|
1806
|
+
// parameters. Selecting alone would discard that handoff.
|
|
1807
|
+
showNative(path: raw, replace: false, props: props)
|
|
1808
|
+
} else {
|
|
1809
|
+
selectTabAsIfTapped(tab)
|
|
1810
|
+
}
|
|
1805
1811
|
return
|
|
1806
1812
|
}
|
|
1807
1813
|
if hybrid.routes.match(raw) != nil {
|
|
@@ -94,6 +94,9 @@ enum CraftNativeFiles {
|
|
|
94
94
|
let dayAgo = Date().addingTimeInterval(-86_400)
|
|
95
95
|
let folders = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey])) ?? []
|
|
96
96
|
for folder in folders {
|
|
97
|
+
// Only generic per-file folders: private voice captures have their
|
|
98
|
+
// own launch lifetime and must not be swept during a live recording.
|
|
99
|
+
guard UUID(uuidString: folder.lastPathComponent) != nil else { continue }
|
|
97
100
|
let created = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast
|
|
98
101
|
if created < dayAgo { try? fm.removeItem(at: folder) }
|
|
99
102
|
}
|
|
@@ -5804,6 +5804,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5804
5804
|
self.routeProps = routeProps
|
|
5805
5805
|
self.isAppScript = isAppScript
|
|
5806
5806
|
super.init(nibName: nil, bundle: nil)
|
|
5807
|
+
CraftVoiceRecording.prepareForLaunch(includeLegacy: config.enableAudioRecording)
|
|
5807
5808
|
}
|
|
5808
5809
|
|
|
5809
5810
|
required init?(coder: NSCoder) {
|
|
@@ -2,26 +2,133 @@ import AVFoundation
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import UIKit
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
protocol CraftVoiceRecorder: AnyObject {
|
|
6
|
+
func record(forDuration duration: TimeInterval) -> Bool
|
|
7
|
+
func stop()
|
|
8
|
+
}
|
|
9
|
+
extension AVAudioRecorder: CraftVoiceRecorder {}
|
|
10
|
+
|
|
11
|
+
/// The main thread owns requests; audio and encoder work belong to one serial
|
|
12
|
+
/// queue. Cancellation invalidates a request immediately, even while AVFoundation
|
|
13
|
+
/// is starting, so a late permission/encoder answer cannot resurrect it.
|
|
7
14
|
final class CraftVoiceRecording {
|
|
8
15
|
static let shared = CraftVoiceRecording()
|
|
9
|
-
private
|
|
10
|
-
private
|
|
11
|
-
private
|
|
16
|
+
private static let fileQueue = DispatchQueue(label: "craft.voice-recording-files", qos: .utility)
|
|
17
|
+
private static let launchCleanup: Void = discardAbandonedRecordings(includeLegacy: false)
|
|
18
|
+
private static let legacyCleanup: Void = discardAbandonedRecordings()
|
|
19
|
+
private static let folderName = "voice-recordings"
|
|
20
|
+
|
|
21
|
+
/// Temporary voice files have a launch lifetime, unlike picked files and
|
|
22
|
+
/// downloads. Run once across all screens, without touching audio hardware.
|
|
23
|
+
static func prepareForLaunch(includeLegacy: Bool = true) {
|
|
24
|
+
_ = launchCleanup
|
|
25
|
+
if includeLegacy { _ = legacyCleanup }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func discardAbandonedRecordings(in root: URL = CraftNativeFiles.root, includeLegacy: Bool = true) {
|
|
29
|
+
let fm = FileManager.default
|
|
30
|
+
// Also migrate the recorder's earlier UUID folders at craft-files' root.
|
|
31
|
+
let voice = root.appendingPathComponent(folderName, isDirectory: true)
|
|
32
|
+
for scope in includeLegacy ? [root, voice] : [voice] {
|
|
33
|
+
guard (try? scope.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { continue }
|
|
34
|
+
let folders = (try? fm.contentsOfDirectory(at: scope, includingPropertiesForKeys: [.isSymbolicLinkKey])) ?? []
|
|
35
|
+
for folder in folders {
|
|
36
|
+
guard UUID(uuidString: folder.lastPathComponent) != nil,
|
|
37
|
+
(try? folder.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { continue }
|
|
38
|
+
let file = folder.appendingPathComponent("voice-feedback.m4a")
|
|
39
|
+
let values = try? file.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey])
|
|
40
|
+
guard values?.isRegularFile == true, values?.isSymbolicLink != true else { continue }
|
|
41
|
+
try? fm.removeItem(at: file)
|
|
42
|
+
if (try? fm.contentsOfDirectory(atPath: folder.path).isEmpty) == true { try? fm.removeItem(at: folder) }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static func newRecordingFile() throws -> URL {
|
|
48
|
+
prepareForLaunch()
|
|
49
|
+
let root = CraftNativeFiles.root.appendingPathComponent(folderName, isDirectory: true)
|
|
50
|
+
guard (try? root.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink != true else { throw CocoaError(.fileWriteNoPermission) }
|
|
51
|
+
let folder = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
52
|
+
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
53
|
+
let file = folder.appendingPathComponent("voice-feedback.m4a")
|
|
54
|
+
do {
|
|
55
|
+
// An owned placeholder lets cancellation erase the path even when
|
|
56
|
+
// AVFoundation has not finished constructing its recorder yet.
|
|
57
|
+
try Data().write(to: file)
|
|
58
|
+
return file
|
|
59
|
+
} catch {
|
|
60
|
+
try? FileManager.default.removeItem(at: folder)
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
private final class Session {
|
|
65
|
+
let key: String
|
|
66
|
+
var file: URL?
|
|
67
|
+
var recorder: CraftVoiceRecorder? // audio queue only
|
|
68
|
+
var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
|
|
69
|
+
var deadline: DispatchWorkItem?
|
|
70
|
+
private let lock = NSLock()
|
|
71
|
+
private var cancelled = false
|
|
72
|
+
init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
73
|
+
self.key = key
|
|
74
|
+
self.completion = completion
|
|
75
|
+
}
|
|
76
|
+
var isCancelled: Bool { lock.withLock { cancelled } }
|
|
77
|
+
func invalidate() { lock.withLock { cancelled = true } }
|
|
78
|
+
func finish(_ result: Result<Any, CraftNativeActionError>) {
|
|
79
|
+
deadline?.cancel()
|
|
80
|
+
deadline = nil
|
|
81
|
+
let done = completion
|
|
82
|
+
completion = nil
|
|
83
|
+
done?(result)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
private var current: Session?
|
|
87
|
+
private let audioQueue: DispatchQueue
|
|
88
|
+
private let permission: (@escaping (Bool) -> Void) -> Void
|
|
89
|
+
private let makeRecorder: (URL) throws -> CraftVoiceRecorder
|
|
90
|
+
private let deactivate: () -> Void
|
|
91
|
+
private let startupTimeout: TimeInterval
|
|
12
92
|
private var backgroundObserver: NSObjectProtocol?
|
|
13
93
|
|
|
14
|
-
|
|
94
|
+
init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
|
|
95
|
+
startupTimeout: TimeInterval = 15,
|
|
96
|
+
permission: @escaping (@escaping (Bool) -> Void) -> Void = { AVAudioSession.sharedInstance().requestRecordPermission($0) },
|
|
97
|
+
makeRecorder: @escaping (URL) throws -> CraftVoiceRecorder = { file in
|
|
98
|
+
let session = AVAudioSession.sharedInstance()
|
|
99
|
+
try session.setCategory(.record, mode: .measurement)
|
|
100
|
+
try session.setActive(true)
|
|
101
|
+
return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
|
|
102
|
+
},
|
|
103
|
+
deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
|
|
104
|
+
Self.prepareForLaunch()
|
|
105
|
+
self.audioQueue = audioQueue
|
|
106
|
+
self.startupTimeout = startupTimeout
|
|
107
|
+
self.permission = permission
|
|
108
|
+
self.makeRecorder = makeRecorder
|
|
109
|
+
self.deactivate = deactivate
|
|
15
110
|
backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.clear() }
|
|
16
111
|
}
|
|
17
112
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
recorder
|
|
22
|
-
|
|
23
|
-
file =
|
|
24
|
-
|
|
113
|
+
deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } }
|
|
114
|
+
|
|
115
|
+
private func erase(_ session: Session) {
|
|
116
|
+
session.recorder?.stop()
|
|
117
|
+
session.recorder = nil
|
|
118
|
+
if let file = session.file { CraftNativeFiles.remove(file.path) }
|
|
119
|
+
deactivate()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
|
|
123
|
+
guard let session = current else { return }
|
|
124
|
+
current = nil
|
|
125
|
+
session.invalidate()
|
|
126
|
+
session.finish(.failure(error))
|
|
127
|
+
if let file = session.file {
|
|
128
|
+
// A stalled encoder must not hold deletion behind its audio queue.
|
|
129
|
+
Self.fileQueue.async { CraftNativeFiles.remove(file.path) }
|
|
130
|
+
}
|
|
131
|
+
audioQueue.async { self.erase(session) }
|
|
25
132
|
}
|
|
26
133
|
|
|
27
134
|
func perform(method: String, args: [Any], isActive: @escaping () -> Bool = { true }, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
@@ -32,43 +139,84 @@ final class CraftVoiceRecording {
|
|
|
32
139
|
func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
|
|
33
140
|
guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
|
|
34
141
|
if method == "cancel" {
|
|
35
|
-
if
|
|
142
|
+
if current?.key == key { clear() }
|
|
36
143
|
completion(.success(true))
|
|
37
144
|
return
|
|
38
145
|
}
|
|
39
146
|
if method == "stop" {
|
|
40
|
-
guard
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
147
|
+
guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
|
|
148
|
+
audioQueue.async {
|
|
149
|
+
guard !session.isCancelled else { return DispatchQueue.main.async { fail("RECORDING_CANCELLED", "Recording was cancelled") } }
|
|
150
|
+
session.recorder?.stop()
|
|
151
|
+
session.recorder = nil
|
|
152
|
+
self.deactivate()
|
|
153
|
+
let result: Result<Any, CraftNativeActionError>
|
|
154
|
+
if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
|
|
155
|
+
let duration = Double(audio.length) / audio.processingFormat.sampleRate
|
|
156
|
+
result = .success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration]))
|
|
157
|
+
} else {
|
|
158
|
+
result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
|
|
159
|
+
}
|
|
160
|
+
DispatchQueue.main.async {
|
|
161
|
+
guard self.current === session, isActive(), !session.isCancelled else {
|
|
162
|
+
if self.current === session { self.clear() }
|
|
163
|
+
fail("RECORDING_CANCELLED", "Recording was cancelled")
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if case .failure = result { self.clear() }
|
|
167
|
+
completion(result)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
47
170
|
return
|
|
48
171
|
}
|
|
49
172
|
guard method == "start" else { fail("UNKNOWN_ACTION", "Unknown voice recording action"); return }
|
|
50
173
|
guard isActive() else { fail("RECORDING_CANCELLED", "The requesting screen has closed"); return }
|
|
51
|
-
guard
|
|
174
|
+
guard current == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
|
|
52
175
|
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
53
176
|
let seconds = (options["maxSeconds"] as? NSNumber)?.doubleValue ?? 300
|
|
54
177
|
guard seconds.isFinite, seconds >= 1, seconds <= 300 else { fail("INVALID_ARGUMENT", "Recordings are limited to five minutes"); return }
|
|
55
|
-
|
|
56
|
-
|
|
178
|
+
let session = Session(key: key, completion: completion)
|
|
179
|
+
current = session
|
|
180
|
+
permission { allowed in
|
|
57
181
|
DispatchQueue.main.async {
|
|
58
|
-
guard self.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
guard
|
|
69
|
-
|
|
182
|
+
guard self.current === session, isActive() else {
|
|
183
|
+
if self.current === session { self.clear() }
|
|
184
|
+
session.finish(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
guard allowed else { self.clear(CraftNativeActionError(code: "PERMISSION_DENIED", message: "Microphone permission was refused")); return }
|
|
188
|
+
do { session.file = try Self.newRecordingFile() }
|
|
189
|
+
catch { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The recording file could not be created")); return }
|
|
190
|
+
guard let file = session.file else { return }
|
|
191
|
+
let deadline = DispatchWorkItem { [weak host = self] in
|
|
192
|
+
guard let host, host.current === session else { return }
|
|
193
|
+
host.clear(CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The microphone took too long to start. Please try again."))
|
|
194
|
+
}
|
|
195
|
+
session.deadline = deadline
|
|
196
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + self.startupTimeout, execute: deadline)
|
|
197
|
+
self.audioQueue.async {
|
|
198
|
+
do {
|
|
199
|
+
guard !session.isCancelled else { return }
|
|
200
|
+
session.recorder = try self.makeRecorder(file)
|
|
201
|
+
guard !session.isCancelled else { self.erase(session); return }
|
|
202
|
+
guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
|
|
203
|
+
throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")
|
|
204
|
+
}
|
|
205
|
+
guard !session.isCancelled else { self.erase(session); return }
|
|
206
|
+
DispatchQueue.main.async {
|
|
207
|
+
guard self.current === session, isActive() else {
|
|
208
|
+
if self.current === session { self.clear() }
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
session.finish(.success(true))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
DispatchQueue.main.async {
|
|
216
|
+
if self.current === session { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
70
219
|
}
|
|
71
|
-
catch { self.clear(); fail("RECORDING_FAILED", "The microphone could not start") }
|
|
72
220
|
}
|
|
73
221
|
}
|
|
74
222
|
}
|
|
@@ -64,7 +64,10 @@ final class DriverTests: XCTestCase {
|
|
|
64
64
|
func get(_ path: String, timeout: Double = 15) -> Data? {
|
|
65
65
|
var result: Data?
|
|
66
66
|
let done = expectation(description: path)
|
|
67
|
-
URLSession.shared.dataTask(with: URL(string: control + path)!) { data,
|
|
67
|
+
URLSession.shared.dataTask(with: URL(string: control + path)!) { data, response, error in
|
|
68
|
+
if error == nil, let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) { result = data }
|
|
69
|
+
done.fulfill()
|
|
70
|
+
}.resume()
|
|
68
71
|
wait(for: [done], timeout: timeout)
|
|
69
72
|
return result
|
|
70
73
|
}
|
|
@@ -332,7 +335,10 @@ final class DriverTests: XCTestCase {
|
|
|
332
335
|
case "net": _ = get("/\(arg)"); note("net \(arg)")
|
|
333
336
|
case "delay": _ = get("/delay?ms=\(arg)"); note("delay \(arg)ms")
|
|
334
337
|
case "exec":
|
|
335
|
-
let
|
|
338
|
+
guard let data = get("/exec?name=\(arg)", timeout: 180), let out = String(data: data, encoding: .utf8) else {
|
|
339
|
+
check(false, "exec \(arg): hook failed or returned no successful response")
|
|
340
|
+
return
|
|
341
|
+
}
|
|
336
342
|
note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
|
|
337
343
|
case "openurl":
|
|
338
344
|
if let url = URL(string: arg) {
|