@craft-native/ios 0.0.344 → 0.0.346

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
@@ -40,7 +40,11 @@ an owned temporary M4A descriptor with its MIME type, size and duration for
40
40
  Permission is answered on the main thread. Recorder startup, stopping and
41
41
  file inspection run on a serial audio queue. Startup has a 15-second deadline
42
42
  after permission is granted; cancellation or timeout prevents a delayed encoder
43
- from publishing a recording. A startup failure leaves the interface responsive.
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.
44
48
 
45
49
  ### Device Access
46
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.344",
3
+ "version": "0.0.346",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9676,7 +9676,7 @@ final class CraftZigRuntime: NSObject {
9676
9676
  enum CraftWebServices {
9677
9677
  private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
9678
9678
  /// Coordinator events a native screen hears too.
9679
- static let forwardedEvents: Set<String> = ["craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
9679
+ static let forwardedEvents: Set<String> = ["craftNetworkChange", "craftLocationUpdate", "craftLocationError", "craftHealthHeartRate", "craftHealthWorkout", "craftMusicChange", "craftHeartRate"]
9680
9680
  static let event = Notification.Name("craftWebServicesEvent")
9681
9681
 
9682
9682
  static var isAvailable: Bool { CraftSwiftShim.coordinator != nil }
@@ -43,6 +43,7 @@ enum CraftNativeActions {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
45
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
46
+ values.append("network")
46
47
  values.append("nativePager")
47
48
  values.append("storageCompareAndSet")
48
49
  values.append("storageSetIfAbsent")
@@ -245,6 +246,9 @@ enum CraftNativeActions {
245
246
  }
246
247
  authenticate(requestToken: requestToken, args: args, completion: completion)
247
248
  return
249
+ case ("Network", "getStatus"):
250
+ CraftWebServices.perform(action: "getNetworkStatus", body: [:]) { complete(requestToken, $0, completion) }
251
+ return
248
252
  case ("Network", "fetch"):
249
253
  fetch(requestToken: requestToken, args: args, completion: completion)
250
254
  return
@@ -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