@craft-native/ios 0.0.451 → 0.0.467

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.
@@ -6,6 +6,72 @@ import Network
6
6
  import PhotosUI
7
7
  import UIKit
8
8
  import UniformTypeIdentifiers
9
+ import UserNotifications
10
+
11
+ /// UserNotifications takes ownership of attachment files. Never give it a
12
+ /// picker/download original: stage copies and retain them until `add` answers.
13
+ final class CraftNotificationAttachments {
14
+ let attachments: [UNNotificationAttachment]
15
+ let directory: URL?
16
+
17
+ init(_ raw: Any?) throws {
18
+ guard let raw else { attachments = []; directory = nil; return }
19
+ func invalid(_ message: String) -> NSError {
20
+ NSError(domain: "CraftNotification", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
21
+ }
22
+ guard let entries = raw as? [[String: Any]], entries.count <= 10 else {
23
+ throw invalid("attachments must be an array of at most ten { id, path } entries")
24
+ }
25
+ var identifiers = Set<String>()
26
+ let sources: [(String, URL)] = try entries.map { entry in
27
+ guard let id = entry["id"] as? String, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
28
+ identifiers.insert(id).inserted,
29
+ let path = entry["path"] as? String, let url = CraftNativeFiles.owned(path) else {
30
+ throw invalid("Each attachment needs a unique nonempty id and an existing craft.files path")
31
+ }
32
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
33
+ guard attributes[.type] as? FileAttributeType == .typeRegular,
34
+ let size = attributes[.size] as? NSNumber, size.int64Value > 0, size.int64Value <= 50 * 1024 * 1024 else {
35
+ throw invalid("An attachment must be a nonempty regular file no larger than 50 MiB")
36
+ }
37
+ return (id, url)
38
+ }
39
+ guard !sources.isEmpty else { attachments = []; directory = nil; return }
40
+ let folder = FileManager.default.temporaryDirectory
41
+ .appendingPathComponent("craft-notification-\(UUID().uuidString)", isDirectory: true)
42
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
43
+ do {
44
+ attachments = try sources.map { id, source in
45
+ // Use generated names, keeping the extension for iOS type detection.
46
+ let copy = folder.appendingPathComponent(UUID().uuidString).appendingPathExtension(source.pathExtension)
47
+ try FileManager.default.copyItem(at: source, to: copy)
48
+ // iOS validates supported media, including the lower image/audio
49
+ // limits. A failure rejects the entire notification, never a subset.
50
+ return try UNNotificationAttachment(identifier: id, url: copy, options: nil)
51
+ }
52
+ directory = folder
53
+ } catch {
54
+ try? FileManager.default.removeItem(at: folder)
55
+ throw error
56
+ }
57
+ }
58
+
59
+ func cleanup() {
60
+ // Only our staging folder, never attachment.url (which iOS can move).
61
+ if let directory { try? FileManager.default.removeItem(at: directory) }
62
+ }
63
+
64
+ func schedule(_ request: UNNotificationRequest,
65
+ submit: (UNNotificationRequest, @escaping (Error?) -> Void) -> Void = { UNUserNotificationCenter.current().add($0, withCompletionHandler: $1) },
66
+ completion: @escaping (Error?) -> Void) {
67
+ submit(request) { error in
68
+ self.cleanup()
69
+ completion(error)
70
+ }
71
+ }
72
+
73
+ deinit { cleanup() }
74
+ }
9
75
 
10
76
  // `craft.files` on a native screen: a photo picked from the library (or taken
11
77
  // with the camera), a file downloaded for the share sheet, and those files
@@ -847,6 +913,12 @@ enum CraftNativeFiles {
847
913
  // JavaScript. The screen decides what to keep (`keep`) and what to drop
848
914
  // (`prune`); nothing is evicted behind its back.
849
915
  enum CraftNativeMedia {
916
+ /// A caller-owned account + resource + version identity. Never used as a network URL.
917
+ static func identity(_ source: String, options: [String: Any]) -> String {
918
+ guard let key = options["cacheKey"] as? String, !key.isEmpty, key.utf8.count <= 1024 else { return source }
919
+ return "craft-media-key:" + key
920
+ }
921
+
850
922
  /// Foundation often wraps ENOSPC inside a URL or Cocoa file error.
851
923
  static func errorCode(_ error: Error, fallback: String) -> String {
852
924
  var cause: NSError? = error as NSError
@@ -871,9 +943,40 @@ enum CraftNativeMedia {
871
943
  /// Addresses being fetched now, so a second `keep` waits for the first.
872
944
  private struct Flight {
873
945
  let id: UUID
946
+ let ownership: CraftNativeMediaOwnership.Stamp
874
947
  var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
875
948
  }
876
949
  private static var inFlight: [String: Flight] = [:]
950
+ private static var owner: CraftNativeMediaOwnership?
951
+
952
+ /// Called under the same lock as cache commits. Keep the journal outside
953
+ /// the cache directory so clear/prune cannot erase revocation history.
954
+ private static func ownership() throws -> CraftNativeMediaOwnership {
955
+ if let owner { return owner }
956
+ let value = try CraftNativeMediaOwnership(url: root.deletingLastPathComponent().appendingPathComponent("craft-media-ownership.json"))
957
+ owner = value
958
+ return value
959
+ }
960
+
961
+ private static func ownerKey(_ address: String) -> String {
962
+ String(file(for: address).lastPathComponent.prefix(64))
963
+ }
964
+
965
+ /// Remove only this identity's complete and staging files. A failed
966
+ /// unlink remains observable; its durable tombstone still hides the copy.
967
+ private static func removeFiles(_ address: String) throws -> Int {
968
+ let fm = FileManager.default
969
+ guard fm.fileExists(atPath: root.path) else { return 0 }
970
+ let prefix = ownerKey(address) + "."
971
+ var removed = 0
972
+ var failure: Error?
973
+ for url in try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) where url.lastPathComponent.hasPrefix(prefix) {
974
+ do { try fm.removeItem(at: url); removed += 1 }
975
+ catch { failure = error }
976
+ }
977
+ if let failure { throw failure }
978
+ return removed
979
+ }
877
980
 
878
981
  /// Where `address`'s copy lives (whether or not it is there yet). The
879
982
  /// extension is the address's, because AVPlayer reads the type from it.
@@ -887,8 +990,12 @@ enum CraftNativeMedia {
887
990
  /// The copy of `address` on this phone, or nil.
888
991
  static func local(_ address: String) -> URL? {
889
992
  guard !address.isEmpty else { return nil }
993
+ lock.lock()
994
+ defer { lock.unlock() }
995
+ guard (try? ownership().permitsLocal(ownerKey(address))) == true else { return nil }
890
996
  let url = file(for: address)
891
- return ((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) > 0 ? url : nil
997
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
998
+ return values?.isRegularFile == true && (values?.fileSize ?? 0) > 0 ? url : nil
892
999
  }
893
1000
 
894
1001
  static func describe(_ address: String, _ url: URL) -> [String: Any] {
@@ -899,14 +1006,35 @@ enum CraftNativeMedia {
899
1006
  /// A new download's identity, or nil after joining an existing download.
900
1007
  static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
901
1008
  lock.lock()
902
- defer { lock.unlock() }
903
1009
  if inFlight[address] != nil {
904
1010
  inFlight[address]!.waiting.append(done)
1011
+ lock.unlock()
1012
+ return nil
1013
+ }
1014
+ do {
1015
+ let stamp = try ownership().activate(ownerKey(address)) { _ = try removeFiles(address) }
1016
+ let id = UUID()
1017
+ inFlight[address] = Flight(id: id, ownership: stamp, waiting: [done])
1018
+ lock.unlock()
1019
+ return id
1020
+ } catch {
1021
+ lock.unlock()
1022
+ done(.failure(CraftNativeActionError(code: errorCode(error, fallback: "IO_ERROR"), message: "Media ownership could not be persisted")))
905
1023
  return nil
906
1024
  }
907
- let id = UUID()
908
- inFlight[address] = Flight(id: id, waiting: [done])
909
- return id
1025
+ }
1026
+
1027
+ static func ownershipStamp(_ address: String, flight: UUID) -> CraftNativeMediaOwnership.Stamp? {
1028
+ lock.lock()
1029
+ defer { lock.unlock() }
1030
+ guard let current = inFlight[address], current.id == flight else { return nil }
1031
+ return current.ownership
1032
+ }
1033
+
1034
+ static func acceptsTransfer(_ address: String, stamp: CraftNativeMediaOwnership.Stamp) -> Bool {
1035
+ lock.lock()
1036
+ defer { lock.unlock() }
1037
+ return (try? ownership().accepts(ownerKey(address), stamp: stamp)) == true
910
1038
  }
911
1039
 
912
1040
  /// Whether `flight` is still the download `address`'s keeps wait for.
@@ -925,7 +1053,7 @@ enum CraftNativeMedia {
925
1053
 
926
1054
  /// A finished download moved into place (atomically, so a half-written
927
1055
  /// file is never played), or a refusal with the server's status.
928
- static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil, stillWanted: (() -> Bool)? = nil) -> Result<Any, CraftNativeActionError> {
1056
+ static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil, stamp: CraftNativeMediaOwnership.Stamp? = nil, stillWanted: (() -> Bool)? = nil) -> Result<Any, CraftNativeActionError> {
929
1057
  guard response.statusCode == 200 else {
930
1058
  return .success(["ok": false, "url": address, "status": response.statusCode])
931
1059
  }
@@ -936,11 +1064,15 @@ enum CraftNativeMedia {
936
1064
  if let flight, inFlight[address]?.id != flight {
937
1065
  return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
938
1066
  }
1067
+ let captured = stamp ?? flight.flatMap { inFlight[address]?.id == $0 ? inFlight[address]?.ownership : nil }
1068
+ guard let captured, (try? ownership().accepts(ownerKey(address), stamp: captured)) == true else {
1069
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "Media ownership was retired"))
1070
+ }
939
1071
  // A background transfer from before the cache was cleared (sign-out).
940
1072
  if let stillWanted, !stillWanted() {
941
1073
  return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
942
1074
  }
943
- let staging = root.appendingPathComponent(UUID().uuidString + ".part")
1075
+ let staging = root.appendingPathComponent(ownerKey(address) + "." + UUID().uuidString + ".part")
944
1076
  defer { try? fm.removeItem(at: staging) }
945
1077
  do {
946
1078
  // URL resource values can retain a previous size when a retry
@@ -973,15 +1105,20 @@ enum CraftNativeMedia {
973
1105
  /// End an account's cache lifetime.
974
1106
  static func clear() throws -> Int {
975
1107
  lock.lock()
1108
+ var failure: Error?
1109
+ do { try ownership().clear() }
1110
+ catch { failure = error }
976
1111
  let waiting = inFlight.values.flatMap { $0.waiting }
977
1112
  inFlight.removeAll()
978
1113
  // Under the same lock a background transfer commits under: none
979
1114
  // started before this lands after it.
980
1115
  CraftNativeMediaTransfers.shared.cancelAll()
981
1116
  let fm = FileManager.default
982
- let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
1117
+ var files: [URL] = []
1118
+ do {
1119
+ if fm.fileExists(atPath: root.path) { files = try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) }
1120
+ } catch { failure = error }
983
1121
  var removed = 0
984
- var failure: Error?
985
1122
  for url in files {
986
1123
  do { try fm.removeItem(at: url); removed += 1 }
987
1124
  catch { failure = error }
@@ -992,6 +1129,24 @@ enum CraftNativeMedia {
992
1129
  return removed
993
1130
  }
994
1131
 
1132
+ /// Revoke one cache identity without removing other authorized media.
1133
+ static func remove(_ address: String) throws -> Int {
1134
+ guard !address.isEmpty else { throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "media.remove needs a source or cache key") }
1135
+ lock.lock()
1136
+ var failure: Error?
1137
+ do { try ownership().retire(ownerKey(address)) }
1138
+ catch { failure = error }
1139
+ let waiting = inFlight.removeValue(forKey: address)?.waiting ?? []
1140
+ CraftNativeMediaTransfers.shared.cancelRetired(address: address)
1141
+ var removed = 0
1142
+ do { removed = try removeFiles(address) }
1143
+ catch { failure = error }
1144
+ lock.unlock()
1145
+ for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "Media ownership was retired"))) }
1146
+ if let failure { throw failure }
1147
+ return removed
1148
+ }
1149
+
995
1150
  /// Removes every copy whose address is not in `keep`; answers how many.
996
1151
  static func prune(keep: [String]) -> Int {
997
1152
  CraftNativeMediaTransfers.shared.cancel(except: keep + Array(inFlightAddresses()))
@@ -1048,6 +1203,24 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1048
1203
  let generation: Int
1049
1204
  }
1050
1205
 
1206
+ private struct Description: Codable {
1207
+ let address: String
1208
+ let ownership: CraftNativeMediaOwnership.Stamp
1209
+ }
1210
+
1211
+ private static func describe(_ address: String, ownership: CraftNativeMediaOwnership.Stamp) -> String {
1212
+ // No network capability is added to the journal: only the task that
1213
+ // already owns the authorized request carries its cache identity.
1214
+ let data = try! JSONEncoder().encode(Description(address: address, ownership: ownership))
1215
+ return "craft-media-v2:" + data.base64EncodedString()
1216
+ }
1217
+
1218
+ private static func description(_ task: URLSessionTask) -> Description? {
1219
+ guard let text = task.taskDescription, text.hasPrefix("craft-media-v2:"),
1220
+ let data = Data(base64Encoded: String(text.dropFirst("craft-media-v2:".count))) else { return nil }
1221
+ return try? JSONDecoder().decode(Description.self, from: data)
1222
+ }
1223
+
1051
1224
  private let lock = NSLock()
1052
1225
  private var transfers: [Int: Transfer] = [:]
1053
1226
  /// What each finished download came to, until its task completes.
@@ -1071,7 +1244,10 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1071
1244
  guard let self else { return }
1072
1245
  self.lock.lock()
1073
1246
  for case let task as URLSessionDownloadTask in tasks where task.state == .running || task.state == .suspended {
1074
- if let address = task.taskDescription, self.transfers[task.taskIdentifier] == nil { self.restored[address] = task }
1247
+ // A pre-upgrade address-only description cannot establish
1248
+ // durable ownership. A new authorized keep can replace it.
1249
+ guard let description = Self.description(task) else { task.cancel(); continue }
1250
+ if self.transfers[task.taskIdentifier] == nil { self.restored[description.address] = task }
1075
1251
  }
1076
1252
  self.lock.unlock()
1077
1253
  }
@@ -1082,15 +1258,26 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1082
1258
  }
1083
1259
 
1084
1260
  /// Fetches `request` into the copy for `address`, finishing `flight`.
1085
- func start(_ request: URLRequest, address: String, flight: UUID, wifiOnly: Bool) -> URLSessionTask {
1261
+ func start(_ request: URLRequest, address: String, flight: UUID, ownership: CraftNativeMediaOwnership.Stamp, wifiOnly: Bool) -> URLSessionTask {
1086
1262
  lock.lock()
1087
1263
  let task: URLSessionDownloadTask
1088
1264
  if let earlier = restored.removeValue(forKey: address), earlier.state == .running || earlier.state == .suspended {
1089
- task = earlier
1265
+ if Self.description(earlier)?.ownership == ownership,
1266
+ earlier.originalRequest?.url == request.url && earlier.originalRequest?.allHTTPHeaderFields == request.allHTTPHeaderFields {
1267
+ task = earlier
1268
+ }
1269
+ else {
1270
+ // A queued capability can expire while the app is suspended.
1271
+ // Retain its cache identity, replace only its authorized source.
1272
+ earlier.taskDescription = nil
1273
+ earlier.cancel()
1274
+ task = session.downloadTask(with: request)
1275
+ task.taskDescription = Self.describe(address, ownership: ownership)
1276
+ }
1090
1277
  }
1091
1278
  else {
1092
1279
  task = session.downloadTask(with: request)
1093
- task.taskDescription = address
1280
+ task.taskDescription = Self.describe(address, ownership: ownership)
1094
1281
  }
1095
1282
  transfers[task.taskIdentifier] = Transfer(address: address, flight: flight, wifiOnly: wifiOnly, generation: generation)
1096
1283
  lock.unlock()
@@ -1122,7 +1309,28 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1122
1309
  generation += 1
1123
1310
  restored.removeAll()
1124
1311
  lock.unlock()
1125
- session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
1312
+ // The asynchronous enumeration may run after a new authorized keep.
1313
+ // Retire only obsolete stamps, never that new generation's task.
1314
+ session.getAllTasks { tasks in
1315
+ for task in tasks {
1316
+ guard let description = Self.description(task), CraftNativeMedia.acceptsTransfer(description.address, stamp: description.ownership) else {
1317
+ task.cancel()
1318
+ continue
1319
+ }
1320
+ }
1321
+ }
1322
+ }
1323
+
1324
+ func cancelRetired(address: String) {
1325
+ lock.lock()
1326
+ restored.removeValue(forKey: address)
1327
+ lock.unlock()
1328
+ session.getAllTasks { tasks in
1329
+ for task in tasks {
1330
+ guard let description = Self.description(task) else { task.cancel(); continue }
1331
+ if description.address == address && !CraftNativeMedia.acceptsTransfer(address, stamp: description.ownership) { task.cancel() }
1332
+ }
1333
+ }
1126
1334
  }
1127
1335
 
1128
1336
  /// Stops the transfers for addresses no longer wanted.
@@ -1132,7 +1340,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1132
1340
  restored = restored.filter { wanted.contains($0.key) }
1133
1341
  lock.unlock()
1134
1342
  session.getAllTasks { tasks in
1135
- for task in tasks where !wanted.contains(task.taskDescription ?? "") { task.cancel() }
1343
+ for task in tasks where !wanted.contains(Self.description(task)?.address ?? "") { task.cancel() }
1136
1344
  }
1137
1345
  }
1138
1346
 
@@ -1142,7 +1350,11 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1142
1350
  lock.unlock()
1143
1351
  // One left by an earlier launch belongs to the generation this launch began with.
1144
1352
  let started = transfer?.generation ?? 0
1145
- guard let address = transfer?.address ?? downloadTask.taskDescription else { return }
1353
+ guard let description = Self.description(downloadTask) else {
1354
+ record(downloadTask, .failure(CraftNativeActionError(code: "CANCELLED", message: "The media transfer has no durable ownership")))
1355
+ return
1356
+ }
1357
+ let address = description.address
1146
1358
  guard let http = downloadTask.response as? HTTPURLResponse else {
1147
1359
  record(downloadTask, .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep received a response that is not HTTP")))
1148
1360
  return
@@ -1150,7 +1362,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1150
1362
  // The system deletes `location` once this returns: move it now. It
1151
1363
  // lands whether its keep is still waiting or was answered early; only
1152
1364
  // a clear since it started (sign-out) keeps it out.
1153
- record(downloadTask, CraftNativeMedia.store(address, at: location, response: http, stillWanted: { [weak self] in self?.isCurrent(started) ?? false }))
1365
+ record(downloadTask, CraftNativeMedia.store(address, at: location, response: http, stamp: description.ownership, stillWanted: { [weak self] in self?.isCurrent(started) ?? false }))
1154
1366
  }
1155
1367
 
1156
1368
  /// How many times the cache was cleared in this launch; a transfer started before the last clear does not land.
@@ -1174,7 +1386,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1174
1386
  lock.lock()
1175
1387
  let transfer = transfers.removeValue(forKey: task.taskIdentifier)
1176
1388
  let result = finished.removeValue(forKey: task.taskIdentifier)
1177
- if let address = task.taskDescription, restored[address] === task { restored.removeValue(forKey: address) }
1389
+ if let address = Self.description(task)?.address, restored[address] === task { restored.removeValue(forKey: address) }
1178
1390
  lock.unlock()
1179
1391
  guard let transfer, let flight = transfer.flight else { return }
1180
1392
  if let error {
@@ -0,0 +1,92 @@
1
+ import Foundation
2
+
3
+ /// Durable ownership of cached media and background transfers. The media
4
+ /// store serializes access to this journal with its file-commit lock.
5
+ final class CraftNativeMediaOwnership {
6
+ struct Stamp: Codable, Equatable {
7
+ let epoch: String
8
+ let revision: String
9
+ }
10
+
11
+ private struct Journal: Codable {
12
+ var version = 1
13
+ var epoch = UUID().uuidString
14
+ var legacyCopiesAllowed = true
15
+ var revisions: [String: String] = [:]
16
+ var revoked: Set<String> = []
17
+ var authorized: Set<String> = []
18
+ }
19
+
20
+ private let url: URL
21
+ private var journal: Journal
22
+ private var persistenceFailure: Error?
23
+
24
+ init(url: URL) throws {
25
+ self.url = url
26
+ if FileManager.default.fileExists(atPath: url.path) {
27
+ journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: url))
28
+ guard journal.version == 1 else { throw CocoaError(.fileReadCorruptFile) }
29
+ } else {
30
+ journal = Journal()
31
+ try persist(journal)
32
+ }
33
+ }
34
+
35
+ func permitsLocal(_ identity: String) -> Bool {
36
+ persistenceFailure == nil && !journal.revoked.contains(identity)
37
+ && (journal.legacyCopiesAllowed || journal.authorized.contains(identity))
38
+ }
39
+
40
+ func accepts(_ identity: String, stamp: Stamp) -> Bool {
41
+ permitsLocal(identity) && stamp.epoch == journal.epoch
42
+ && stamp.revision == (journal.revisions[identity] ?? "")
43
+ }
44
+
45
+ /// A new explicit authorized keep may use this identity again. Old bytes
46
+ /// must be removed before lifting a tombstone, including after a failed
47
+ /// unlink. Its old transfer stamp never becomes current again.
48
+ func activate(_ identity: String, removeRetiredFiles: () throws -> Void) throws -> Stamp {
49
+ if persistenceFailure != nil { try persist(journal) }
50
+ if !permitsLocal(identity) {
51
+ try removeRetiredFiles()
52
+ var next = journal
53
+ next.revoked.remove(identity)
54
+ next.authorized.insert(identity)
55
+ try persist(next)
56
+ }
57
+ return Stamp(epoch: journal.epoch, revision: journal.revisions[identity] ?? "")
58
+ }
59
+
60
+ /// Persist before cancelling or unlinking: a task that outlives this
61
+ /// process cannot regain ownership on the next launch.
62
+ func retire(_ identity: String) throws {
63
+ var next = journal
64
+ next.revisions[identity] = UUID().uuidString
65
+ next.revoked.insert(identity)
66
+ try persist(next)
67
+ }
68
+
69
+ /// Sign-out invalidates all existing stamps and all copies, even if a
70
+ /// filesystem error prevents unlinking one of them.
71
+ func clear() throws {
72
+ var next = Journal()
73
+ next.legacyCopiesAllowed = false
74
+ try persist(next)
75
+ }
76
+
77
+ private func persist(_ next: Journal) throws {
78
+ // A failed journal write is observable and fails closed in this
79
+ // process. A later retire/clear can retry once storage is writable.
80
+ journal = next
81
+ do {
82
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
83
+ let encoder = JSONEncoder()
84
+ encoder.outputFormatting = [.sortedKeys]
85
+ try encoder.encode(next).write(to: url, options: .atomic)
86
+ persistenceFailure = nil
87
+ } catch {
88
+ persistenceFailure = error
89
+ throw error
90
+ }
91
+ }
92
+ }