@craft-native/ios 0.0.341 → 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/README.md
CHANGED
|
@@ -29,6 +29,19 @@ decoration. Resuming does not replay it. Available on both native renderers.
|
|
|
29
29
|
- **Audio Recording** - AVAudioRecorder with compressed output
|
|
30
30
|
- **Video Recording** - UIImagePickerController video capture
|
|
31
31
|
|
|
32
|
+
### Native voice capture
|
|
33
|
+
|
|
34
|
+
With `enableAudioRecording`, the native renderer exposes
|
|
35
|
+
`craft.audioRecording.start(owner, { maxSeconds })`, `stop(owner)` and
|
|
36
|
+
`cancel(owner)`. Use a fresh UUID owner and at most 300 seconds. Stop returns
|
|
37
|
+
an owned temporary M4A descriptor with its MIME type, size and duration for
|
|
38
|
+
`craft.files` multipart uploads. Cancel and backgrounding erase the recording.
|
|
39
|
+
|
|
40
|
+
Permission is answered on the main thread. Recorder startup, stopping and
|
|
41
|
+
file inspection run on a serial audio queue. Startup has a 15-second deadline
|
|
42
|
+
after permission is granted; cancellation or timeout prevents a delayed encoder
|
|
43
|
+
from publishing a recording. A startup failure leaves the interface responsive.
|
|
44
|
+
|
|
32
45
|
### Device Access
|
|
33
46
|
|
|
34
47
|
- **Camera & Gallery** - Take photos or pick from gallery
|
|
@@ -1342,3 +1355,5 @@ when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
|
|
|
1342
1355
|
`sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
|
|
1343
1356
|
weight, italic style and system text scaling. A missing custom face falls through
|
|
1344
1357
|
to the next family rather than silently replacing a requested mono font with sans.
|
|
1358
|
+
|
|
1359
|
+
The simulator driver requires a successful HTTP response from each control-server `exec` hook. A failed or unavailable hook fails XCTest and stops the journey before its next command.
|
package/package.json
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 {
|
|
@@ -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) {
|