craft-native 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.
@@ -630,7 +630,10 @@ internal class CraftNativeCapabilities(
630
630
  private fun sweepFiles() {
631
631
  val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
632
632
  fileRoot.listFiles()?.forEach { file ->
633
- if (file.lastModified() < cutoff) file.deleteRecursively()
633
+ // Voice captures have a separate launch lifetime; sweep only
634
+ // generic per-file UUID folders here.
635
+ val owned = runCatching { UUID.fromString(file.name).toString().equals(file.name, ignoreCase = true) }.getOrDefault(false)
636
+ if (owned && file.lastModified() < cutoff) file.deleteRecursively()
634
637
  }
635
638
  }
636
639
 
@@ -85,6 +85,7 @@ internal class CraftNativeServices(
85
85
  {{NATIVE_WALLET_FIELDS}}
86
86
 
87
87
  init {
88
+ prepareVoiceFiles(context, config.optBoolean("enableAudioRecording"))
88
89
  if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
89
90
  // Native screens must also hear a recording started by the page.
90
91
  // Keep this process sink independent from the page's listener.
@@ -629,7 +630,7 @@ internal class CraftNativeServices(
629
630
  private var voiceFile: File? = null
630
631
  private var pendingVoicePermission: ((Boolean) -> Unit)? = null
631
632
 
632
- fun pauseVoice() { if (voiceRecorder != null) cancelVoice() }
633
+ fun pauseVoice() { if (voiceRecorder != null || voiceFile != null) cancelVoice() }
633
634
  private fun cancelVoice() {
634
635
  voiceOwner = null
635
636
  val recorder = voiceRecorder
@@ -676,7 +677,7 @@ internal class CraftNativeServices(
676
677
  if (voiceOwner != key) { done(failure("RECORDING_CANCELLED", "Recording was cancelled")); return@start }
677
678
  if (!allowed) { cancelVoice(); done(failure("PERMISSION_DENIED", "Microphone permission was refused")); return@start }
678
679
  try {
679
- val folder = File(context.cacheDir, "craft-files/" + UUID.randomUUID()).apply { mkdirs() }
680
+ val folder = File(context.cacheDir, "craft-files/voice-recordings/" + UUID.randomUUID()).apply { mkdirs() }
680
681
  val file = File(folder, "voice-feedback.m4a")
681
682
  voiceFile = file
682
683
  @Suppress("DEPRECATION")
@@ -816,6 +817,38 @@ internal class CraftNativeServices(
816
817
  private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
817
818
 
818
819
  companion object {
820
+ private var voiceFilesPrepared = false
821
+ private var legacyVoiceFilesPrepared = false
822
+
823
+ @Synchronized
824
+ private fun prepareVoiceFiles(context: Context, includeLegacy: Boolean) {
825
+ val root = File(context.cacheDir, "craft-files")
826
+ if (!voiceFilesPrepared) {
827
+ discardAbandonedVoiceFiles(root, false)
828
+ voiceFilesPrepared = true
829
+ }
830
+ if (includeLegacy && !legacyVoiceFilesPrepared) {
831
+ discardAbandonedVoiceFiles(root)
832
+ legacyVoiceFilesPrepared = true
833
+ }
834
+ }
835
+
836
+ internal fun discardAbandonedVoiceFiles(root: File, includeLegacy: Boolean = true) {
837
+ val base = root.canonicalFile
838
+ val voice = File(base, "voice-recordings")
839
+ for (scope in if (includeLegacy) listOf(base, voice) else listOf(voice)) {
840
+ if (scope.canonicalFile != scope.absoluteFile) continue
841
+ for (folder in scope.listFiles().orEmpty()) {
842
+ val owned = runCatching { UUID.fromString(folder.name).toString().equals(folder.name, ignoreCase = true) }.getOrDefault(false)
843
+ if (!owned || folder.canonicalFile != folder.absoluteFile) continue
844
+ val file = File(folder, "voice-feedback.m4a")
845
+ if (!file.isFile || file.canonicalFile != file.absoluteFile) continue
846
+ file.delete()
847
+ if (folder.listFiles()?.isEmpty() == true) folder.delete()
848
+ }
849
+ }
850
+ }
851
+
819
852
  const val PUSH_PERMISSION_REQUEST = 4207
820
853
  const val LOCATION_PERMISSION_REQUEST = 4208
821
854
  }
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.341";
3025
+ var version = "0.0.345";
3026
3026
 
3027
3027
  // bin/cli.ts
3028
3028
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.341";
378
+ var version = "0.0.345";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.341";
120
+ var version = "0.0.345";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.341",
3
+ "version": "0.0.345",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",