@craft-native/ios 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/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.340",
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 {
@@ -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 { complete(requestToken, storage(method: method, args: args), completion) }
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
- CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
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
- do { return try CraftNativeActions.storageJSON(forKey: key) }
6474
- catch {
6475
- if let context = JSContext.current() {
6476
- context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: 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
- CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
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
- /// 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) {