craft-native 0.0.344 → 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.344";
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.344";
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.344";
120
+ var version = "0.0.345";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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) {
@@ -13,6 +13,54 @@ extension AVAudioRecorder: CraftVoiceRecorder {}
13
13
  /// is starting, so a late permission/encoder answer cannot resurrect it.
14
14
  final class CraftVoiceRecording {
15
15
  static let shared = CraftVoiceRecording()
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
+ }
16
64
  private final class Session {
17
65
  let key: String
18
66
  var file: URL?
@@ -53,6 +101,7 @@ final class CraftVoiceRecording {
53
101
  return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
54
102
  },
55
103
  deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
104
+ Self.prepareForLaunch()
56
105
  self.audioQueue = audioQueue
57
106
  self.startupTimeout = startupTimeout
58
107
  self.permission = permission
@@ -75,6 +124,10 @@ final class CraftVoiceRecording {
75
124
  current = nil
76
125
  session.invalidate()
77
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
+ }
78
131
  audioQueue.async { self.erase(session) }
79
132
  }
80
133
 
@@ -132,7 +185,7 @@ final class CraftVoiceRecording {
132
185
  return
133
186
  }
134
187
  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") }
188
+ do { session.file = try Self.newRecordingFile() }
136
189
  catch { self.clear(CraftNativeActionError(code: "RECORDING_FAILED", message: "The recording file could not be created")); return }
137
190
  guard let file = session.file else { return }
138
191
  let deadline = DispatchWorkItem { [weak host = self] in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.344",
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>",