@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.341",
3
+ "version": "0.0.344",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- selectTabAsIfTapped(tab)
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
- /// One owner at a time, including the asynchronous permission prompt. A late
6
- /// permission answer cannot start recording after its screen/account left.
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 var owner: String?
10
- private var recorder: AVAudioRecorder?
11
- private var file: URL?
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
- private init() {
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
- private func clear() {
19
- owner = nil
20
- recorder?.stop()
21
- recorder = nil
22
- if let file { CraftNativeFiles.remove(file.path) }
23
- file = nil
24
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
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 owner == key { clear() }
89
+ if current?.key == key { clear() }
36
90
  completion(.success(true))
37
91
  return
38
92
  }
39
93
  if method == "stop" {
40
- guard owner == key, let file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
41
- recorder?.stop()
42
- recorder = nil
43
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
44
- guard let audio = try? AVAudioFile(forReading: file), audio.length > 0 else { clear(); fail("EMPTY_RECORDING", "The recording was empty"); return }
45
- let duration = Double(audio.length) / audio.processingFormat.sampleRate
46
- completion(.success(CraftNativeFiles.describe(file, type: "audio/mp4", extra: ["durationSeconds": duration])))
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 owner == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
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
- owner = key
56
- AVAudioSession.sharedInstance().requestRecordPermission { allowed in
125
+ let session = Session(key: key, completion: completion)
126
+ current = session
127
+ permission { allowed in
57
128
  DispatchQueue.main.async {
58
- guard self.owner == key, isActive() else { if self.owner == key { self.clear() }; fail("RECORDING_CANCELLED", "Recording was cancelled"); return }
59
- guard allowed else { self.clear(); fail("PERMISSION_DENIED", "Microphone permission was refused"); return }
60
- do {
61
- let session = AVAudioSession.sharedInstance()
62
- try session.setCategory(.record, mode: .measurement)
63
- try session.setActive(true)
64
- let file = try CraftNativeFiles.newFile(named: "voice-feedback.m4a")
65
- self.file = file
66
- let recorder = try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
67
- self.recorder = recorder
68
- guard recorder.record(forDuration: max(0.5, seconds - 0.25)) else { throw CraftNativeActionError(code: "RECORDING_FAILED", message: "The microphone could not start") }
69
- completion(.success(true))
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, _, _ in result = data; done.fulfill() }.resume()
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 out = get("/exec?name=\(arg)", timeout: 180).flatMap { String(data: $0, encoding: .utf8) } ?? "no answer"
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) {