craft-native 0.0.340 → 0.0.344
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 +11 -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/CraftNativeActions.swift +7 -1
- package/dist/ios/templates/CraftNativeScreen.swift +62 -8
- package/dist/ios/templates/CraftVoiceRecording.swift +132 -37
- package/dist/ios/templates/testing/DriverTests.swift.template +8 -2
- package/package.json +1 -1
|
@@ -37,6 +37,7 @@ import java.net.URLConnection
|
|
|
37
37
|
import java.util.UUID
|
|
38
38
|
import java.util.concurrent.ConcurrentHashMap
|
|
39
39
|
import java.util.concurrent.Executors
|
|
40
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
40
41
|
import kotlin.math.max
|
|
41
42
|
import kotlin.math.roundToInt
|
|
42
43
|
|
|
@@ -86,6 +87,7 @@ internal class CraftNativeCapabilities(
|
|
|
86
87
|
private val databaseExecutor = Executors.newSingleThreadExecutor()
|
|
87
88
|
private val networkExecutor = Executors.newCachedThreadPool()
|
|
88
89
|
private val cancelled = ConcurrentHashMap.newKeySet<String>()
|
|
90
|
+
private val closed = AtomicBoolean(false)
|
|
89
91
|
private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
|
|
90
92
|
private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
|
|
91
93
|
private var pendingFilePick: PendingFilePick? = null
|
|
@@ -135,6 +137,10 @@ internal class CraftNativeCapabilities(
|
|
|
135
137
|
args: JSONArray,
|
|
136
138
|
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
137
139
|
) {
|
|
140
|
+
if (closed.get()) {
|
|
141
|
+
mainHandler.post { completion(failure("HOST_CLOSED", "The native capability host is closed")) }
|
|
142
|
+
return
|
|
143
|
+
}
|
|
138
144
|
cancelled.remove(requestToken)
|
|
139
145
|
if (version != CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) {
|
|
140
146
|
complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
|
|
@@ -150,6 +156,7 @@ internal class CraftNativeCapabilities(
|
|
|
150
156
|
else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
|
|
151
157
|
}
|
|
152
158
|
"Storage" -> mainHandler.post {
|
|
159
|
+
if (cancelled.remove(requestToken) || closed.get()) return@post
|
|
153
160
|
val answer = runCatching { storage(method, args) }
|
|
154
161
|
.getOrElse { failure("STORAGE_ERROR", it.message ?: "Storage operation failed") }
|
|
155
162
|
complete(requestToken, answer, completion)
|
|
@@ -199,6 +206,8 @@ internal class CraftNativeCapabilities(
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
fun close() {
|
|
209
|
+
if (!closed.compareAndSet(false, true)) return
|
|
210
|
+
cancelled.clear()
|
|
202
211
|
if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
|
|
203
212
|
databaseExecutor.shutdownNow()
|
|
204
213
|
networkExecutor.shutdownNow()
|
|
@@ -653,9 +662,9 @@ internal class CraftNativeCapabilities(
|
|
|
653
662
|
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
654
663
|
) {
|
|
655
664
|
if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
|
|
656
|
-
if (!cancelled.remove(requestToken)) completion(result)
|
|
665
|
+
if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
|
|
657
666
|
} else mainHandler.post {
|
|
658
|
-
if (!cancelled.remove(requestToken)) completion(result)
|
|
667
|
+
if (!cancelled.remove(requestToken) && !closed.get()) completion(result)
|
|
659
668
|
}
|
|
660
669
|
}
|
|
661
670
|
|
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.344";
|
|
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 {
|
|
@@ -203,7 +203,13 @@ enum CraftNativeActions {
|
|
|
203
203
|
case ("Clipboard", "read"):
|
|
204
204
|
sync = perform(action: "clipboardRead", body: [:], config: config)
|
|
205
205
|
case ("Storage", _):
|
|
206
|
-
DispatchQueue.main.async {
|
|
206
|
+
DispatchQueue.main.async {
|
|
207
|
+
cancellationLock.lock()
|
|
208
|
+
let cancelled = cancelledRequests.remove(requestToken) != nil
|
|
209
|
+
cancellationLock.unlock()
|
|
210
|
+
guard !cancelled else { return }
|
|
211
|
+
complete(requestToken, storage(method: method, args: args), completion)
|
|
212
|
+
}
|
|
207
213
|
return
|
|
208
214
|
case ("SecureStorage", _):
|
|
209
215
|
guard config.enableSecureStorage else {
|
|
@@ -15,6 +15,45 @@ import UIKit
|
|
|
15
15
|
|
|
16
16
|
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
17
17
|
|
|
18
|
+
/// A retained JavaScript context must lose synchronous storage access when
|
|
19
|
+
/// its screen closes. Only the actual synchronous unmount callback may finish
|
|
20
|
+
/// its cleanup; queued script work runs after that allowance ends.
|
|
21
|
+
private final class CraftNativeStorageLifetime {
|
|
22
|
+
private let lock = NSLock()
|
|
23
|
+
private var closed = false
|
|
24
|
+
private var unmounting = false
|
|
25
|
+
|
|
26
|
+
func close() {
|
|
27
|
+
lock.lock()
|
|
28
|
+
closed = true
|
|
29
|
+
lock.unlock()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func unmount(_ work: () -> Void) {
|
|
33
|
+
lock.lock()
|
|
34
|
+
unmounting = true
|
|
35
|
+
lock.unlock()
|
|
36
|
+
defer {
|
|
37
|
+
lock.lock()
|
|
38
|
+
unmounting = false
|
|
39
|
+
lock.unlock()
|
|
40
|
+
}
|
|
41
|
+
work()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func access<T>(closedValue: T, _ work: () -> T) -> T {
|
|
45
|
+
lock.lock()
|
|
46
|
+
defer { lock.unlock() }
|
|
47
|
+
guard !closed || unmounting else {
|
|
48
|
+
if let context = JSContext.current() {
|
|
49
|
+
context.exception = JSValue(newErrorFromMessage: "SCREEN_CLOSED: The native screen is closed", in: context)
|
|
50
|
+
}
|
|
51
|
+
return closedValue
|
|
52
|
+
}
|
|
53
|
+
return work()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
18
57
|
/// Foundation's `NSNumber(0/1) is Bool` is true even for JSON numbers.
|
|
19
58
|
/// Check the Core Foundation identity so real booleans stay invalid numeric
|
|
20
59
|
/// props while zero lengths, flex factors and opacity 1 remain numbers.
|
|
@@ -5751,6 +5790,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5751
5790
|
weak var hybridEvents: CraftHybridScreenEvents?
|
|
5752
5791
|
private var hybridRendered = false
|
|
5753
5792
|
private var runtimeDisposed = false
|
|
5793
|
+
private let storageLifetime = CraftNativeStorageLifetime()
|
|
5754
5794
|
/// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
|
|
5755
5795
|
/// no screen, loaded once and never shown, that runs for the app's life.
|
|
5756
5796
|
/// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
|
|
@@ -5998,6 +6038,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5998
6038
|
private func disposeRuntime() {
|
|
5999
6039
|
guard !runtimeDisposed else { return }
|
|
6000
6040
|
runtimeDisposed = true
|
|
6041
|
+
storageLifetime.close()
|
|
6042
|
+
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
6043
|
+
pendingCapabilityDeadlines.removeAll()
|
|
6044
|
+
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
6045
|
+
pendingCapabilityRequests.removeAll()
|
|
6001
6046
|
if let owner = voiceRecordingOwner {
|
|
6002
6047
|
voiceRecordingOwner = nil
|
|
6003
6048
|
CraftVoiceRecording.shared.perform(method: "cancel", args: [owner]) { _ in }
|
|
@@ -6007,7 +6052,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6007
6052
|
disposeRenderedTree()
|
|
6008
6053
|
let context = jsContext
|
|
6009
6054
|
let route = routeName ?? "screen"
|
|
6010
|
-
|
|
6055
|
+
let storageLifetime = self.storageLifetime
|
|
6056
|
+
CraftNativeScriptThread.run(label: "unmount \(route)") {
|
|
6057
|
+
storageLifetime.unmount { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
|
|
6058
|
+
}
|
|
6011
6059
|
audio.close()
|
|
6012
6060
|
sounds.close()
|
|
6013
6061
|
streams.close()
|
|
@@ -6469,17 +6517,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6469
6517
|
let log: @convention(block) (String, String, String) -> Void = { level, category, message in
|
|
6470
6518
|
CraftNativeConsole.write(level, category: category, message: message)
|
|
6471
6519
|
}
|
|
6520
|
+
let storageLifetime = self.storageLifetime
|
|
6472
6521
|
let storageGet: @convention(block) (String) -> String? = { key in
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
context
|
|
6522
|
+
storageLifetime.access(closedValue: nil) {
|
|
6523
|
+
do { return try CraftNativeActions.storageJSON(forKey: key) }
|
|
6524
|
+
catch {
|
|
6525
|
+
if let context = JSContext.current() {
|
|
6526
|
+
context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
|
|
6527
|
+
}
|
|
6528
|
+
return nil
|
|
6477
6529
|
}
|
|
6478
|
-
return nil
|
|
6479
6530
|
}
|
|
6480
6531
|
}
|
|
6481
6532
|
let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
|
|
6482
|
-
|
|
6533
|
+
storageLifetime.access(closedValue: false) {
|
|
6534
|
+
CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
|
|
6535
|
+
}
|
|
6483
6536
|
}
|
|
6484
6537
|
let snapshotGet: @convention(block) (String) -> String? = { name in
|
|
6485
6538
|
CraftSnapshots.read(name)
|
|
@@ -7613,6 +7666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7613
7666
|
navigationController?.popToRootViewController(animated: true)
|
|
7614
7667
|
}
|
|
7615
7668
|
case "API_REQUEST":
|
|
7669
|
+
guard !runtimeDisposed else { return }
|
|
7616
7670
|
guard let id = message["id"] as? String else { return }
|
|
7617
7671
|
let args = payload["args"] as? [Any] ?? []
|
|
7618
7672
|
let requestToken = "\(capabilityScope)/\(id)"
|
|
@@ -7682,7 +7736,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7682
7736
|
case "API_CANCEL":
|
|
7683
7737
|
guard let id = payload["requestId"] as? String else { return }
|
|
7684
7738
|
let requestToken = "\(capabilityScope)/\(id)"
|
|
7685
|
-
pendingCapabilityRequests.remove(requestToken)
|
|
7739
|
+
guard pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
7686
7740
|
pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
7687
7741
|
screenRequestCancellers.removeValue(forKey: requestToken)?()
|
|
7688
7742
|
CraftNativeActions.cancel(requestToken: requestToken)
|
|
@@ -2,26 +2,80 @@ 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
|
-
|
|
11
|
-
|
|
16
|
+
private final class Session {
|
|
17
|
+
let key: String
|
|
18
|
+
var file: URL?
|
|
19
|
+
var recorder: CraftVoiceRecorder? // audio queue only
|
|
20
|
+
var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
|
|
21
|
+
var deadline: DispatchWorkItem?
|
|
22
|
+
private let lock = NSLock()
|
|
23
|
+
private var cancelled = false
|
|
24
|
+
init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
25
|
+
self.key = key
|
|
26
|
+
self.completion = completion
|
|
27
|
+
}
|
|
28
|
+
var isCancelled: Bool { lock.withLock { cancelled } }
|
|
29
|
+
func invalidate() { lock.withLock { cancelled = true } }
|
|
30
|
+
func finish(_ result: Result<Any, CraftNativeActionError>) {
|
|
31
|
+
deadline?.cancel()
|
|
32
|
+
deadline = nil
|
|
33
|
+
let done = completion
|
|
34
|
+
completion = nil
|
|
35
|
+
done?(result)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
private var current: Session?
|
|
39
|
+
private let audioQueue: DispatchQueue
|
|
40
|
+
private let permission: (@escaping (Bool) -> Void) -> Void
|
|
41
|
+
private let makeRecorder: (URL) throws -> CraftVoiceRecorder
|
|
42
|
+
private let deactivate: () -> Void
|
|
43
|
+
private let startupTimeout: TimeInterval
|
|
12
44
|
private var backgroundObserver: NSObjectProtocol?
|
|
13
45
|
|
|
14
|
-
|
|
46
|
+
init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
|
|
47
|
+
startupTimeout: TimeInterval = 15,
|
|
48
|
+
permission: @escaping (@escaping (Bool) -> Void) -> Void = { AVAudioSession.sharedInstance().requestRecordPermission($0) },
|
|
49
|
+
makeRecorder: @escaping (URL) throws -> CraftVoiceRecorder = { file in
|
|
50
|
+
let session = AVAudioSession.sharedInstance()
|
|
51
|
+
try session.setCategory(.record, mode: .measurement)
|
|
52
|
+
try session.setActive(true)
|
|
53
|
+
return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
|
|
54
|
+
},
|
|
55
|
+
deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
|
|
56
|
+
self.audioQueue = audioQueue
|
|
57
|
+
self.startupTimeout = startupTimeout
|
|
58
|
+
self.permission = permission
|
|
59
|
+
self.makeRecorder = makeRecorder
|
|
60
|
+
self.deactivate = deactivate
|
|
15
61
|
backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in self?.clear() }
|
|
16
62
|
}
|
|
17
63
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
recorder
|
|
22
|
-
|
|
23
|
-
file =
|
|
24
|
-
|
|
64
|
+
deinit { if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } }
|
|
65
|
+
|
|
66
|
+
private func erase(_ session: Session) {
|
|
67
|
+
session.recorder?.stop()
|
|
68
|
+
session.recorder = nil
|
|
69
|
+
if let file = session.file { CraftNativeFiles.remove(file.path) }
|
|
70
|
+
deactivate()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
|
|
74
|
+
guard let session = current else { return }
|
|
75
|
+
current = nil
|
|
76
|
+
session.invalidate()
|
|
77
|
+
session.finish(.failure(error))
|
|
78
|
+
audioQueue.async { self.erase(session) }
|
|
25
79
|
}
|
|
26
80
|
|
|
27
81
|
func perform(method: String, args: [Any], isActive: @escaping () -> Bool = { true }, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
@@ -32,43 +86,84 @@ final class CraftVoiceRecording {
|
|
|
32
86
|
func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
|
|
33
87
|
guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
|
|
34
88
|
if method == "cancel" {
|
|
35
|
-
if
|
|
89
|
+
if current?.key == key { clear() }
|
|
36
90
|
completion(.success(true))
|
|
37
91
|
return
|
|
38
92
|
}
|
|
39
93
|
if method == "stop" {
|
|
40
|
-
guard
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
94
|
+
guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
|
|
95
|
+
audioQueue.async {
|
|
96
|
+
guard !session.isCancelled else { return DispatchQueue.main.async { fail("RECORDING_CANCELLED", "Recording was cancelled") } }
|
|
97
|
+
session.recorder?.stop()
|
|
98
|
+
session.recorder = nil
|
|
99
|
+
self.deactivate()
|
|
100
|
+
let result: Result<Any, CraftNativeActionError>
|
|
101
|
+
if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
|
|
102
|
+
let duration = Double(audio.length) / audio.processingFormat.sampleRate
|
|
103
|
+
result = .success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration]))
|
|
104
|
+
} else {
|
|
105
|
+
result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
|
|
106
|
+
}
|
|
107
|
+
DispatchQueue.main.async {
|
|
108
|
+
guard self.current === session, isActive(), !session.isCancelled else {
|
|
109
|
+
if self.current === session { self.clear() }
|
|
110
|
+
fail("RECORDING_CANCELLED", "Recording was cancelled")
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if case .failure = result { self.clear() }
|
|
114
|
+
completion(result)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
47
117
|
return
|
|
48
118
|
}
|
|
49
119
|
guard method == "start" else { fail("UNKNOWN_ACTION", "Unknown voice recording action"); return }
|
|
50
120
|
guard isActive() else { fail("RECORDING_CANCELLED", "The requesting screen has closed"); return }
|
|
51
|
-
guard
|
|
121
|
+
guard current == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
|
|
52
122
|
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
53
123
|
let seconds = (options["maxSeconds"] as? NSNumber)?.doubleValue ?? 300
|
|
54
124
|
guard seconds.isFinite, seconds >= 1, seconds <= 300 else { fail("INVALID_ARGUMENT", "Recordings are limited to five minutes"); return }
|
|
55
|
-
|
|
56
|
-
|
|
125
|
+
let session = Session(key: key, completion: completion)
|
|
126
|
+
current = session
|
|
127
|
+
permission { allowed in
|
|
57
128
|
DispatchQueue.main.async {
|
|
58
|
-
guard self.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
guard
|
|
69
|
-
|
|
129
|
+
guard self.current === session, isActive() else {
|
|
130
|
+
if self.current === session { self.clear() }
|
|
131
|
+
session.finish(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
guard allowed else { self.clear(CraftNativeActionError(code: "PERMISSION_DENIED", message: "Microphone permission was refused")); return }
|
|
135
|
+
do { session.file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a") }
|
|
136
|
+
catch { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The recording file could not be created")); return }
|
|
137
|
+
guard let file = session.file else { return }
|
|
138
|
+
let deadline = DispatchWorkItem { [weak host = self] in
|
|
139
|
+
guard let host, host.current === session else { return }
|
|
140
|
+
host.clear(CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The microphone took too long to start. Please try again."))
|
|
141
|
+
}
|
|
142
|
+
session.deadline = deadline
|
|
143
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + self.startupTimeout, execute: deadline)
|
|
144
|
+
self.audioQueue.async {
|
|
145
|
+
do {
|
|
146
|
+
guard !session.isCancelled else { return }
|
|
147
|
+
session.recorder = try self.makeRecorder(file)
|
|
148
|
+
guard !session.isCancelled else { self.erase(session); return }
|
|
149
|
+
guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
|
|
150
|
+
throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")
|
|
151
|
+
}
|
|
152
|
+
guard !session.isCancelled else { self.erase(session); return }
|
|
153
|
+
DispatchQueue.main.async {
|
|
154
|
+
guard self.current === session, isActive() else {
|
|
155
|
+
if self.current === session { self.clear() }
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
session.finish(.success(true))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
DispatchQueue.main.async {
|
|
163
|
+
if self.current === session { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start")) }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
70
166
|
}
|
|
71
|
-
catch { self.clear(); fail("RECORDING_FAILED", "The microphone could not start") }
|
|
72
167
|
}
|
|
73
168
|
}
|
|
74
169
|
}
|
|
@@ -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) {
|