@craft-native/ios 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/README.md CHANGED
@@ -29,6 +29,23 @@ 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. Deletion runs independently of
44
+ encoder work, so a stalled encoder cannot retain a cancelled capture. Voice files
45
+ use their own temporary namespace and are erased on the next native launch after
46
+ an interruption or process termination, including the earlier recorder's M4A files.
47
+ Picked files and downloads retain their separate lifetime.
48
+
32
49
  ### Device Access
33
50
 
34
51
  - **Camera & Gallery** - Take photos or pick from gallery
@@ -1342,3 +1359,5 @@ when available. `monospace`/`ui-monospace`, `serif`/`ui-serif` and
1342
1359
  `sans-serif`/`ui-sans-serif`/`system-ui` select platform fonts, retaining requested
1343
1360
  weight, italic style and system text scaling. A missing custom face falls through
1344
1361
  to the next family rather than silently replacing a requested mono font with sans.
1362
+
1363
+ 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.345",
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 {
@@ -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
- /// 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 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
- private init() {
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
- 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)
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 owner == key { clear() }
142
+ if current?.key == key { clear() }
36
143
  completion(.success(true))
37
144
  return
38
145
  }
39
146
  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])))
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 owner == nil else { fail("RECORDING_BUSY", "Another microphone session is still active"); return }
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
- owner = key
56
- AVAudioSession.sharedInstance().requestRecordPermission { allowed in
178
+ let session = Session(key: key, completion: completion)
179
+ current = session
180
+ permission { allowed in
57
181
  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))
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, _, _ 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) {