@craft-native/ios 0.0.237 → 0.0.241

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.
@@ -136,9 +136,12 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
136
136
  static let maxRecordedSamples = 50_000
137
137
 
138
138
  private let clock: () -> Date
139
+ private let recordingDirectory: URL?
139
140
 
140
- init(now: @escaping () -> Date = Date.init) {
141
+ init(now: @escaping () -> Date = Date.init, recordingDirectory: URL? = nil, archiveWriter: @escaping CraftRecordingArchive.Writer = CraftRecordingArchive.writeAtomically) {
141
142
  self.clock = now
143
+ self.recordingDirectory = recordingDirectory
144
+ self.archiveWriter = archiveWriter
142
145
  super.init()
143
146
  }
144
147
 
@@ -169,6 +172,8 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
169
172
  private var recoveryTimer: Timer?
170
173
  private var hasSubscribed = false
171
174
 
175
+ private var archiveWriter: CraftRecordingArchive.Writer = CraftRecordingArchive.writeAtomically
176
+ private var recordingActive = false
172
177
  private var recordingId: String?
173
178
  private var recordingStartedAt: Double?
174
179
  private var recordingPaused = false
@@ -199,8 +204,53 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
199
204
  /// One call from a page or a native screen, by method name.
200
205
  func perform(method: String, args: [Any], completion: @escaping Completion) {
201
206
  let options = args.first as? [String: Any] ?? [:]
207
+ if method == "acknowledgeRecording" && !options.keys.contains("expectedId") {
208
+ return completion(.failure(CraftHeartRateError(message: "Acknowledgement needs expectedId", code: "INVALID_ARGUMENT")))
209
+ }
202
210
  DispatchQueue.main.async {
203
- self.restoreRecording()
211
+ do { try self.restoreRecording() }
212
+ catch { return completion(.failure(CraftHeartRateError(message: error.localizedDescription, code: "RECORDING_STORAGE_ERROR"))) }
213
+ if ["getRecordingState", "readRecording", "stopRecording", "acknowledgeRecording"].contains(method),
214
+ options.keys.contains("expectedId") {
215
+ guard let id = options["expectedId"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
216
+ return completion(.failure(CraftHeartRateError(message: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")))
217
+ }
218
+ do {
219
+ if method == "acknowledgeRecording" {
220
+ guard self.recordingId != id || !self.recordingActive else {
221
+ return completion(.failure(CraftHeartRateError(message: "The recording is still active", code: "RECORDING_ALREADY_ACTIVE")))
222
+ }
223
+ var known = try self.recordingId == id || CraftRecordingArchive.isAcknowledged(self.directory, "heart-rate", id)
224
+ if !known { known = try CraftRecordingArchive.read(self.directory, "heart-rate", id) != nil }
225
+ guard known else { return completion(.failure(CraftHeartRateError(message: "Unknown recording ID", code: "RECORDING_CONFLICT"))) }
226
+ try CraftRecordingArchive.acknowledge(self.directory, "heart-rate", id)
227
+ if self.recordingId == id {
228
+ if FileManager.default.fileExists(atPath: self.samplesURL.path) { try FileManager.default.removeItem(at: self.samplesURL) }
229
+ self.recordingId = nil
230
+ self.recordingStartedAt = nil
231
+ self.recordedCount = 0
232
+ try self.persistRecording()
233
+ }
234
+ return completion(.success(true))
235
+ }
236
+ if var archived = try CraftRecordingArchive.read(self.directory, "heart-rate", id) {
237
+ if method == "readRecording" {
238
+ let since = (options["since"] as? NSNumber)?.doubleValue
239
+ let samples = archived["samples"] as? [[String: Any]] ?? []
240
+ return completion(.success(samples.filter { since == nil || (($0["timestamp"] as? NSNumber)?.doubleValue ?? 0) > since! }))
241
+ }
242
+ if method == "getRecordingState" { archived.removeValue(forKey: "samples") }
243
+ return completion(.success(archived))
244
+ }
245
+ if let error = self.recordingIdentityError(options) { return completion(.failure(error)) }
246
+ } catch let error as CraftNativeActionError {
247
+ return completion(.failure(CraftHeartRateError(message: error.message, code: error.code)))
248
+ } catch { return completion(.failure(CraftHeartRateError(message: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR"))) }
249
+ }
250
+ if ["pauseRecording", "resumeRecording", "stopRecording", "readRecording"].contains(method),
251
+ let error = self.recordingIdentityError(options) {
252
+ return completion(.failure(error))
253
+ }
204
254
  switch method {
205
255
  case "getState":
206
256
  self.recoverQuietBroadcast()
@@ -214,12 +264,14 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
214
264
  case "connect": self.connect(id: options["id"] as? String, wait: true, completion: completion)
215
265
  case "reconnect": self.connect(id: nil, wait: false, completion: completion)
216
266
  case "disconnect": self.disconnect(forget: options["forget"] as? Bool ?? false, completion: completion)
217
- case "startRecording": self.startRecording(completion: completion)
267
+ case "startRecording": self.startRecording(options: options, completion: completion)
218
268
  case "pauseRecording": self.setRecordingPaused(true, completion: completion)
219
269
  case "resumeRecording": self.setRecordingPaused(false, completion: completion)
220
270
  case "stopRecording": self.stopRecording(completion: completion)
221
271
  case "getRecordingState": completion(.success(self.recordingSummary(count: true)))
222
- case "readRecording": completion(.success(self.loadSamples(since: (options["since"] as? NSNumber)?.doubleValue)))
272
+ case "readRecording":
273
+ do { completion(.success(try self.persistedSamples(since: (options["since"] as? NSNumber)?.doubleValue))) }
274
+ catch { completion(.failure(CraftHeartRateError(message: "Unable to read the recording", code: "RECORDING_STORAGE_ERROR"))) }
223
275
  default: completion(.failure(CraftHeartRateError(message: "Unsupported HeartRate method", code: "UNKNOWN_ACTION")))
224
276
  }
225
277
  }
@@ -712,38 +764,46 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
712
764
  // MARK: Recording
713
765
 
714
766
  private var directory: URL {
715
- FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
767
+ recordingDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
716
768
  }
717
769
  private var stateURL: URL { directory.appendingPathComponent("craft-heart-rate-recording-state.json") }
718
770
  private var samplesURL: URL { directory.appendingPathComponent("craft-heart-rate-recording.jsonl") }
719
771
 
720
772
  /// A recording that was running when the app last closed carries on.
721
- private func restoreRecording() {
773
+ private func restoreRecording() throws {
722
774
  guard !restored else { return }
723
- restored = true
724
- guard let data = try? Data(contentsOf: stateURL),
725
- let saved = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
726
- saved["active"] as? Bool == true else { return }
775
+ guard FileManager.default.fileExists(atPath: stateURL.path) else {
776
+ if FileManager.default.fileExists(atPath: samplesURL.path), !(try Data(contentsOf: samplesURL)).isEmpty {
777
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Recording metadata is missing")
778
+ }
779
+ restored = true
780
+ return
781
+ }
782
+ let data = try Data(contentsOf: stateURL)
783
+ guard let saved = try JSONSerialization.jsonObject(with: data) as? [String: Any],
784
+ let active = saved["active"] as? Bool else {
785
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid recording metadata")
786
+ }
787
+ recordingActive = active
727
788
  recordingId = saved["id"] as? String
728
789
  recordingStartedAt = saved["startedAt"] as? Double
729
790
  recordingPaused = saved["paused"] as? Bool ?? false
730
- recordedCount = loadSamples(since: nil).count
791
+ if let id = recordingId, try CraftRecordingArchive.isAcknowledged(directory, "heart-rate", id) {
792
+ try CraftRecordingArchive.acknowledge(directory, "heart-rate", id)
793
+ if FileManager.default.fileExists(atPath: samplesURL.path) { try FileManager.default.removeItem(at: samplesURL) }
794
+ recordingId = nil
795
+ recordingStartedAt = nil
796
+ recordingActive = false
797
+ recordingPaused = false
798
+ try persistRecording()
799
+ }
800
+ recordedCount = try persistedSamples(since: nil).count
801
+ restored = true
731
802
  }
732
803
 
733
- private func persistRecording() {
734
- let saved: [String: Any] = [
735
- "active": recordingId != nil,
736
- "paused": recordingPaused,
737
- "id": recordingId ?? NSNull(),
738
- "startedAt": recordingStartedAt ?? NSNull(),
739
- ]
740
- do {
741
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
742
- try JSONSerialization.data(withJSONObject: saved).write(to: stateURL, options: .atomic)
743
- protect(stateURL)
744
- } catch {
745
- print("Unable to persist the heart-rate recording state: \(error)")
746
- }
804
+ private func persistRecording() throws {
805
+ let saved: [String: Any] = ["active": recordingActive, "paused": recordingPaused, "id": recordingId ?? NSNull(), "startedAt": recordingStartedAt ?? NSNull()]
806
+ try CraftRecordingArchive.writeAtomically(JSONSerialization.data(withJSONObject: saved), stateURL)
747
807
  }
748
808
 
749
809
  private func protect(_ url: URL) {
@@ -757,7 +817,7 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
757
817
  private func recordingSummary(count: Bool) -> [String: Any] {
758
818
  var summary: [String: Any] = [
759
819
  "id": recordingId ?? NSNull(),
760
- "active": recordingId != nil,
820
+ "active": recordingActive,
761
821
  "paused": recordingPaused,
762
822
  "startedAt": recordingStartedAt ?? NSNull(),
763
823
  ]
@@ -765,46 +825,80 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
765
825
  return summary
766
826
  }
767
827
 
768
- private func startRecording(completion: @escaping Completion) {
828
+ private func startRecording(options: [String: Any], completion: @escaping Completion) {
829
+ guard !recordingActive else {
830
+ return completion(.failure(CraftHeartRateError(message: "A heart-rate recording is already active", code: "RECORDING_ALREADY_ACTIVE")))
831
+ }
832
+ let id: String
769
833
  do {
834
+ id = try CraftRecordingArchive.startId(options, directory: directory, kind: "heart-rate", currentId: recordingId)
835
+ try CraftRecordingArchive.save(directory, "heart-rate", recordingSummary(count: true), samples: try persistedSamples(since: nil), writer: archiveWriter)
770
836
  try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
771
837
  try Data().write(to: samplesURL, options: .atomic)
772
838
  protect(samplesURL)
839
+ } catch let error as CraftNativeActionError {
840
+ return completion(.failure(CraftHeartRateError(message: error.message, code: error.code)))
773
841
  } catch {
774
842
  return completion(.failure(CraftHeartRateError(message: "Unable to create the heart-rate recording", code: "RECORDING_STORAGE_ERROR")))
775
843
  }
776
- recordingId = UUID().uuidString
844
+ let previous = (recordingActive, recordingId, recordingStartedAt, recordingPaused, recordedCount)
845
+ recordingActive = true
846
+ recordingId = id
777
847
  recordingStartedAt = (clock().timeIntervalSince1970 * 1000).rounded()
778
848
  recordingPaused = false
779
849
  recordedCount = 0
780
- persistRecording()
850
+ do { try persistRecording() }
851
+ catch {
852
+ (recordingActive, recordingId, recordingStartedAt, recordingPaused, recordedCount) = previous
853
+ return completion(.failure(CraftHeartRateError(message: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR")))
854
+ }
781
855
  completion(.success(recordingSummary(count: true)))
782
856
  }
783
857
 
858
+ /// Runs on the same main-queue turn as the read or mutation it guards.
859
+ private func recordingIdentityError(_ options: [String: Any]) -> CraftHeartRateError? {
860
+ guard options.keys.contains("expectedId") else { return nil }
861
+ guard let expected = options["expectedId"] as? String,
862
+ !expected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
863
+ return CraftHeartRateError(message: "expectedId must be a nonempty recording ID", code: "INVALID_ARGUMENT")
864
+ }
865
+ guard recordingId == expected else {
866
+ return CraftHeartRateError(message: "The active heart-rate recording has changed", code: "RECORDING_CONFLICT")
867
+ }
868
+ return nil
869
+ }
870
+
784
871
  private func setRecordingPaused(_ paused: Bool, completion: @escaping Completion) {
785
- guard recordingId != nil else {
872
+ guard recordingActive else {
786
873
  return completion(.failure(CraftHeartRateError(message: "No active heart-rate recording", code: "NO_ACTIVE_RECORDING")))
787
874
  }
875
+ let previous = recordingPaused
788
876
  recordingPaused = paused
789
- persistRecording()
877
+ do { try persistRecording() }
878
+ catch {
879
+ recordingPaused = previous
880
+ return completion(.failure(CraftHeartRateError(message: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR")))
881
+ }
790
882
  completion(.success(recordingSummary(count: true)))
791
883
  }
792
884
 
793
885
  private func stopRecording(completion: @escaping Completion) {
794
- var summary = recordingSummary(count: true)
795
- summary["active"] = false
796
- summary["samples"] = loadSamples(since: nil)
797
- recordingId = nil
798
- recordingStartedAt = nil
799
- recordingPaused = false
800
- recordedCount = 0
801
- persistRecording()
802
- try? FileManager.default.removeItem(at: samplesURL)
803
- completion(.success(summary))
886
+ do {
887
+ let samples = try persistedSamples(since: nil)
888
+ let wasActive = recordingActive
889
+ let wasPaused = recordingPaused
890
+ recordingActive = false
891
+ recordingPaused = false
892
+ do { try persistRecording() }
893
+ catch { recordingActive = wasActive; recordingPaused = wasPaused; throw error }
894
+ var summary = recordingSummary(count: true)
895
+ summary["samples"] = samples
896
+ completion(.success(summary))
897
+ } catch { completion(.failure(CraftHeartRateError(message: "Unable to persist the recording", code: "RECORDING_STORAGE_ERROR"))) }
804
898
  }
805
899
 
806
900
  private func appendSample(_ sample: [String: Any]) {
807
- guard recordingId != nil, !recordingPaused, recordedCount < Self.maxRecordedSamples,
901
+ guard recordingActive, !recordingPaused, recordedCount < Self.maxRecordedSamples,
808
902
  let json = try? JSONSerialization.data(withJSONObject: sample) else { return }
809
903
  var line = json
810
904
  line.append(0x0A)
@@ -820,8 +914,11 @@ final class CraftHeartRate: NSObject, CBCentralManagerDelegate, CBPeripheralDele
820
914
  }
821
915
 
822
916
  /// The recording's samples, oldest first; only those after `since` (ms) when given.
823
- private func loadSamples(since: Double?) -> [[String: Any]] {
824
- guard let text = try? String(contentsOf: samplesURL, encoding: .utf8) else { return [] }
917
+ private func loadSamples(since: Double?) -> [[String: Any]] { (try? persistedSamples(since: since)) ?? [] }
918
+
919
+ private func persistedSamples(since: Double?) throws -> [[String: Any]] {
920
+ if recordingId == nil { return [] }
921
+ let text = try String(contentsOf: samplesURL, encoding: .utf8)
825
922
  return text.split(separator: "\n").compactMap { line in
826
923
  guard let data = line.data(using: .utf8),
827
924
  let sample = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
@@ -42,6 +42,9 @@ enum CraftNativeActions {
42
42
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
43
43
  // as it arrives) need nothing the app has to declare.
44
44
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation"]
45
+ values.append("storageCompareAndSet")
46
+ values.append("storageSetIfAbsent")
47
+ values.append("crypto")
45
48
  if config.enableBiometric { values.append("biometric") }
46
49
  if config.enableSecureStorage { values.append("secureStorage") }
47
50
  if config.enableLocalDatabase { values.append("database") }
@@ -62,7 +65,11 @@ enum CraftNativeActions {
62
65
  // GPS and the route recording are the web view's coordinator's
63
66
  // (CraftWebServices); an app with no web view has the coordinator
64
67
  // all the same (CraftHybrid.configure).
65
- if config.enableGeolocation { values.append("geolocation") }
68
+ if config.enableGeolocation {
69
+ values.append("geolocation")
70
+ if CraftZigRuntime.supportsRecordingOwnership { values.append("locationRecordingOwnership") }
71
+ if CraftZigRuntime.supportsRecordingIntentIdentity { values.append("locationRecordingIntentIdentity") }
72
+ }
66
73
  // The Music app's song and transport are the coordinator's too.
67
74
  if config.enableAppleMusic { values.append("music") }
68
75
  // `craft.background.complete(ok, id)`: the answer to a silent push or
@@ -70,7 +77,7 @@ enum CraftNativeActions {
70
77
  if config.backgroundRefresh?.enabled == true || config.enablePushNotifications { values.append("background") }
71
78
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
72
79
  // so it needs no web view.
73
- if config.enableHeartRate { values.append("heartRate") }
80
+ if config.enableHeartRate { values.append(contentsOf: ["heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"]) }
74
81
  if config.enableBluetooth { values.append("bluetooth") }
75
82
  // Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
76
83
  // also the app's own service.
@@ -92,6 +99,8 @@ enum CraftNativeActions {
92
99
  /// Nil means this small shared router does not own the action.
93
100
  static func perform(action: String, body: [String: Any], config: CraftConfig) -> Result<Any, CraftNativeActionError>? {
94
101
  switch action {
102
+ case "cryptoRandomUUID":
103
+ return .success(UUID().uuidString.lowercased())
95
104
  case "getDeviceInfo":
96
105
  return .success(deviceInfo())
97
106
  case "haptic":
@@ -162,6 +171,8 @@ enum CraftNativeActions {
162
171
 
163
172
  let sync: Result<Any, CraftNativeActionError>?
164
173
  switch (module, method) {
174
+ case ("Crypto", "randomUUID"):
175
+ sync = .success(UUID().uuidString.lowercased())
165
176
  case ("Device", "getInfo"):
166
177
  sync = perform(action: "getDeviceInfo", body: [:], config: config)
167
178
  case ("CrashReporter", "capture"):
@@ -187,7 +198,8 @@ enum CraftNativeActions {
187
198
  case ("Clipboard", "read"):
188
199
  sync = perform(action: "clipboardRead", body: [:], config: config)
189
200
  case ("Storage", _):
190
- sync = storage(method: method, args: args)
201
+ DispatchQueue.main.async { complete(requestToken, storage(method: method, args: args), completion) }
202
+ return
191
203
  case ("SecureStorage", _):
192
204
  guard config.enableSecureStorage else {
193
205
  complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
@@ -398,6 +410,7 @@ enum CraftNativeActions {
398
410
  "startRecording": "startLocationRecording", "pauseRecording": "pauseLocationRecording",
399
411
  "resumeRecording": "resumeLocationRecording", "stopRecording": "stopLocationRecording",
400
412
  "getRecordingState": "getLocationRecordingState", "readRecording": "readLocationRecording",
413
+ "acknowledgeRecording": "acknowledgeLocationRecording",
401
414
  ]
402
415
  guard let action = actions[method] else {
403
416
  complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Location method"), completion)
@@ -840,44 +853,32 @@ enum CraftNativeActions {
840
853
  }
841
854
 
842
855
  private static func storage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
843
- if method == "clear" {
844
- for key in UserDefaults.standard.dictionaryRepresentation().keys where key.hasPrefix(storagePrefix) {
845
- UserDefaults.standard.removeObject(forKey: key)
846
- }
847
- return .success(true)
848
- }
849
- if method == "keys" {
850
- let keys = UserDefaults.standard.dictionaryRepresentation().keys
851
- .filter { $0.hasPrefix(storagePrefix) }
852
- .map { String($0.dropFirst(storagePrefix.count)) }
853
- .sorted()
854
- return .success(keys)
855
- }
856
- guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Storage needs a non-empty key") }
857
- let defaultsKey = storagePrefix + key
858
- switch method {
859
- case "get":
860
- guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return .success(NSNull()) }
861
- do {
862
- return .success(try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]))
863
- } catch {
864
- return failure("STORAGE_CORRUPT", "Stored value is not valid JSON")
865
- }
866
- case "set":
867
- guard args.count == 2 else { return failure("INVALID_ARGUMENT", "Storage.set needs a key and value") }
868
- do {
869
- let data = try JSONSerialization.data(withJSONObject: args[1], options: [.fragmentsAllowed])
870
- UserDefaults.standard.set(data, forKey: defaultsKey)
856
+ do {
857
+ if method == "clear" { try CraftPersistentStorage.clear(prefix: storagePrefix); return .success(true) }
858
+ if method == "keys" { return .success(try CraftPersistentStorage.keys(prefix: storagePrefix)) }
859
+ guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Storage needs a non-empty key") }
860
+ switch method {
861
+ case "get":
862
+ guard let data = try CraftPersistentStorage.get(key, prefix: storagePrefix) else { return .success(NSNull()) }
863
+ return .success(try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed))
864
+ case "set":
865
+ guard args.count == 2 else { return failure("INVALID_ARGUMENT", "Storage.set needs a key and value") }
866
+ let data = try JSONSerialization.data(withJSONObject: args[1], options: .fragmentsAllowed)
867
+ try CraftPersistentStorage.set(key, data: data, prefix: storagePrefix)
871
868
  return .success(true)
872
- } catch {
873
- return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
869
+ case "compareAndSet":
870
+ guard args.count == 3 else { return failure("INVALID_ARGUMENT", "Storage.compareAndSet needs a key, expected value and next value") }
871
+ return .success(try CraftPersistentStorage.compareAndSet(key, expected: args[1], value: args[2], prefix: storagePrefix))
872
+ case "setIfAbsent":
873
+ guard args.count == 2 else { return failure("INVALID_ARGUMENT", "Storage.setIfAbsent needs a key and value") }
874
+ return .success(try CraftPersistentStorage.setIfAbsent(key, value: args[1], prefix: storagePrefix))
875
+ case "remove":
876
+ try CraftPersistentStorage.set(key, data: nil, prefix: storagePrefix)
877
+ return .success(true)
878
+ default: return failure("UNKNOWN_ACTION", "Unsupported Storage method")
874
879
  }
875
- case "remove":
876
- UserDefaults.standard.removeObject(forKey: defaultsKey)
877
- return .success(true)
878
- default:
879
- return failure("UNKNOWN_ACTION", "Unsupported Storage method")
880
- }
880
+ } catch let error as CraftNativeActionError { return .failure(error) }
881
+ catch { return failure("STORAGE_ERROR", "Storage could not persist or read its value") }
881
882
  }
882
883
 
883
884
  private static func secureStorage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
@@ -1389,8 +1390,9 @@ final class CraftNativePasskeys: NSObject, ASAuthorizationControllerDelegate, AS
1389
1390
  extension CraftNativeActions {
1390
1391
  /// The JSON text `craft.storage` keeps under `key`, or nil. The same store
1391
1392
  /// as the asynchronous Storage module, read without a bridge round trip.
1392
- static func storageJSON(forKey key: String) -> String? {
1393
- guard let key = validKey(key), let data = UserDefaults.standard.data(forKey: storagePrefix + key) else { return nil }
1393
+ static func storageJSON(forKey key: String) throws -> String? {
1394
+ guard let key = validKey(key) else { return nil }
1395
+ guard let data = try CraftPersistentStorage.get(key, prefix: storagePrefix) else { return nil }
1394
1396
  return String(decoding: data, as: UTF8.self)
1395
1397
  }
1396
1398
 
@@ -1399,14 +1401,10 @@ extension CraftNativeActions {
1399
1401
  @discardableResult
1400
1402
  static func setStorageJSON(_ json: String?, forKey key: String) -> Bool {
1401
1403
  guard let key = validKey(key) else { return false }
1402
- guard let json else {
1403
- UserDefaults.standard.removeObject(forKey: storagePrefix + key)
1404
+ do {
1405
+ try CraftPersistentStorage.set(key, data: json.map { Data($0.utf8) }, prefix: storagePrefix)
1404
1406
  return true
1405
- }
1406
- let data = Data(json.utf8)
1407
- guard (try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])) != nil else { return false }
1408
- UserDefaults.standard.set(data, forKey: storagePrefix + key)
1409
- return true
1407
+ } catch { return false }
1410
1408
  }
1411
1409
 
1412
1410
  /// The Keychain item the web page's `craft.secureStorage` wrote under