@craft-native/ios 0.0.464 → 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.
package/dist/cli.js CHANGED
@@ -1397,6 +1397,7 @@ function writeShell(output, config) {
1397
1397
  writeSource(join4(output, "Sources", "CraftNativeUI.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeUI.swift"), "utf8"));
1398
1398
  writeSource(join4(output, "Sources", "CraftNativeServices.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeServices.swift"), "utf8"));
1399
1399
  writeSource(join4(output, "Sources", "CraftNativeFiles.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeFiles.swift"), "utf8"));
1400
+ writeSource(join4(output, "Sources", "CraftNativeMediaOwnership.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftNativeMediaOwnership.swift"), "utf8"));
1400
1401
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1401
1402
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1402
1403
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
package/dist/index.js CHANGED
@@ -1611,6 +1611,7 @@ function writeShell(output, config) {
1611
1611
  writeSource(join4(output, "Sources", "CraftNativeUI.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeUI.swift"), "utf8"));
1612
1612
  writeSource(join4(output, "Sources", "CraftNativeServices.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeServices.swift"), "utf8"));
1613
1613
  writeSource(join4(output, "Sources", "CraftNativeFiles.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeFiles.swift"), "utf8"));
1614
+ writeSource(join4(output, "Sources", "CraftNativeMediaOwnership.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftNativeMediaOwnership.swift"), "utf8"));
1614
1615
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1615
1616
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1616
1617
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.464",
3
+ "version": "0.0.467",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -109,6 +109,7 @@ enum CraftNativeActions {
109
109
  // which the native Video element plays in place of the address.
110
110
  values.append("media")
111
111
  values.append("media-cache-key")
112
+ values.append("media-remove")
112
113
  if config.enableCamera { values.append("camera") }
113
114
  if config.enableAudioRecording { values.append("audioRecording") }
114
115
  // craft.crashReporter: the app's crash and error reporter (bughq).
@@ -333,6 +334,24 @@ enum CraftNativeActions {
333
334
  return
334
335
  case ("Media", "path"):
335
336
  sync = .success(CraftNativeMedia.local(CraftNativeMedia.identity(args.first as? String ?? "", options: args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]))?.path ?? NSNull())
337
+ case ("Media", "remove"):
338
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
339
+ if let value = options["cacheKey"], !(value is NSNull) {
340
+ guard let key = value as? String, !key.isEmpty, key.utf8.count <= 1024 else {
341
+ sync = failure("INVALID_ARGUMENT", "media.remove cacheKey must be a nonempty string of at most 1024 bytes")
342
+ break
343
+ }
344
+ }
345
+ let address = CraftNativeMedia.identity(args.first as? String ?? "", options: options)
346
+ guard !address.isEmpty else {
347
+ sync = failure("INVALID_ARGUMENT", "media.remove needs a source or cache key")
348
+ break
349
+ }
350
+ DispatchQueue.global(qos: .utility).async {
351
+ do { complete(requestToken, .success(try CraftNativeMedia.remove(address)), completion) }
352
+ catch { complete(requestToken, failure("IO_ERROR", "The media copy could not be removed"), completion) }
353
+ }
354
+ return
336
355
  case ("Media", "prune"):
337
356
  let keep = (args.first as? [Any] ?? []).compactMap { value -> String? in
338
357
  if let url = value as? String { return url }
@@ -1001,7 +1020,8 @@ enum CraftNativeActions {
1001
1020
  // On the app's background session (CraftNativeMediaTransfers): it
1002
1021
  // carries on while the app is suspended, and a keep the phone cannot
1003
1022
  // fetch now is answered at once while its transfer waits with iOS.
1004
- let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, wifiOnly: wifiOnly)
1023
+ guard let ownership = CraftNativeMedia.ownershipStamp(address, flight: flight) else { return }
1024
+ let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, ownership: ownership, wifiOnly: wifiOnly)
1005
1025
  cancellationLock.lock()
1006
1026
  // Unless it answered already (a queued transfer, or an instant failure).
1007
1027
  if CraftNativeMedia.isWaiting(address, flight: flight) { fetchTasks[requestToken] = task }
@@ -943,9 +943,40 @@ enum CraftNativeMedia {
943
943
  /// Addresses being fetched now, so a second `keep` waits for the first.
944
944
  private struct Flight {
945
945
  let id: UUID
946
+ let ownership: CraftNativeMediaOwnership.Stamp
946
947
  var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
947
948
  }
948
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
+ }
949
980
 
950
981
  /// Where `address`'s copy lives (whether or not it is there yet). The
951
982
  /// extension is the address's, because AVPlayer reads the type from it.
@@ -959,8 +990,12 @@ enum CraftNativeMedia {
959
990
  /// The copy of `address` on this phone, or nil.
960
991
  static func local(_ address: String) -> URL? {
961
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 }
962
996
  let url = file(for: address)
963
- 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
964
999
  }
965
1000
 
966
1001
  static func describe(_ address: String, _ url: URL) -> [String: Any] {
@@ -971,14 +1006,35 @@ enum CraftNativeMedia {
971
1006
  /// A new download's identity, or nil after joining an existing download.
972
1007
  static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
973
1008
  lock.lock()
974
- defer { lock.unlock() }
975
1009
  if inFlight[address] != nil {
976
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")))
977
1023
  return nil
978
1024
  }
979
- let id = UUID()
980
- inFlight[address] = Flight(id: id, waiting: [done])
981
- 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
982
1038
  }
983
1039
 
984
1040
  /// Whether `flight` is still the download `address`'s keeps wait for.
@@ -997,7 +1053,7 @@ enum CraftNativeMedia {
997
1053
 
998
1054
  /// A finished download moved into place (atomically, so a half-written
999
1055
  /// file is never played), or a refusal with the server's status.
1000
- 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> {
1001
1057
  guard response.statusCode == 200 else {
1002
1058
  return .success(["ok": false, "url": address, "status": response.statusCode])
1003
1059
  }
@@ -1008,11 +1064,15 @@ enum CraftNativeMedia {
1008
1064
  if let flight, inFlight[address]?.id != flight {
1009
1065
  return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
1010
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
+ }
1011
1071
  // A background transfer from before the cache was cleared (sign-out).
1012
1072
  if let stillWanted, !stillWanted() {
1013
1073
  return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
1014
1074
  }
1015
- let staging = root.appendingPathComponent(UUID().uuidString + ".part")
1075
+ let staging = root.appendingPathComponent(ownerKey(address) + "." + UUID().uuidString + ".part")
1016
1076
  defer { try? fm.removeItem(at: staging) }
1017
1077
  do {
1018
1078
  // URL resource values can retain a previous size when a retry
@@ -1045,15 +1105,20 @@ enum CraftNativeMedia {
1045
1105
  /// End an account's cache lifetime.
1046
1106
  static func clear() throws -> Int {
1047
1107
  lock.lock()
1108
+ var failure: Error?
1109
+ do { try ownership().clear() }
1110
+ catch { failure = error }
1048
1111
  let waiting = inFlight.values.flatMap { $0.waiting }
1049
1112
  inFlight.removeAll()
1050
1113
  // Under the same lock a background transfer commits under: none
1051
1114
  // started before this lands after it.
1052
1115
  CraftNativeMediaTransfers.shared.cancelAll()
1053
1116
  let fm = FileManager.default
1054
- 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 }
1055
1121
  var removed = 0
1056
- var failure: Error?
1057
1122
  for url in files {
1058
1123
  do { try fm.removeItem(at: url); removed += 1 }
1059
1124
  catch { failure = error }
@@ -1064,6 +1129,24 @@ enum CraftNativeMedia {
1064
1129
  return removed
1065
1130
  }
1066
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
+
1067
1150
  /// Removes every copy whose address is not in `keep`; answers how many.
1068
1151
  static func prune(keep: [String]) -> Int {
1069
1152
  CraftNativeMediaTransfers.shared.cancel(except: keep + Array(inFlightAddresses()))
@@ -1120,6 +1203,24 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1120
1203
  let generation: Int
1121
1204
  }
1122
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
+
1123
1224
  private let lock = NSLock()
1124
1225
  private var transfers: [Int: Transfer] = [:]
1125
1226
  /// What each finished download came to, until its task completes.
@@ -1143,7 +1244,10 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1143
1244
  guard let self else { return }
1144
1245
  self.lock.lock()
1145
1246
  for case let task as URLSessionDownloadTask in tasks where task.state == .running || task.state == .suspended {
1146
- 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 }
1147
1251
  }
1148
1252
  self.lock.unlock()
1149
1253
  }
@@ -1154,11 +1258,12 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1154
1258
  }
1155
1259
 
1156
1260
  /// Fetches `request` into the copy for `address`, finishing `flight`.
1157
- 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 {
1158
1262
  lock.lock()
1159
1263
  let task: URLSessionDownloadTask
1160
1264
  if let earlier = restored.removeValue(forKey: address), earlier.state == .running || earlier.state == .suspended {
1161
- if earlier.originalRequest?.url == request.url && earlier.originalRequest?.allHTTPHeaderFields == request.allHTTPHeaderFields {
1265
+ if Self.description(earlier)?.ownership == ownership,
1266
+ earlier.originalRequest?.url == request.url && earlier.originalRequest?.allHTTPHeaderFields == request.allHTTPHeaderFields {
1162
1267
  task = earlier
1163
1268
  }
1164
1269
  else {
@@ -1167,12 +1272,12 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1167
1272
  earlier.taskDescription = nil
1168
1273
  earlier.cancel()
1169
1274
  task = session.downloadTask(with: request)
1170
- task.taskDescription = address
1275
+ task.taskDescription = Self.describe(address, ownership: ownership)
1171
1276
  }
1172
1277
  }
1173
1278
  else {
1174
1279
  task = session.downloadTask(with: request)
1175
- task.taskDescription = address
1280
+ task.taskDescription = Self.describe(address, ownership: ownership)
1176
1281
  }
1177
1282
  transfers[task.taskIdentifier] = Transfer(address: address, flight: flight, wifiOnly: wifiOnly, generation: generation)
1178
1283
  lock.unlock()
@@ -1204,7 +1309,28 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1204
1309
  generation += 1
1205
1310
  restored.removeAll()
1206
1311
  lock.unlock()
1207
- 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
+ }
1208
1334
  }
1209
1335
 
1210
1336
  /// Stops the transfers for addresses no longer wanted.
@@ -1214,7 +1340,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1214
1340
  restored = restored.filter { wanted.contains($0.key) }
1215
1341
  lock.unlock()
1216
1342
  session.getAllTasks { tasks in
1217
- 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() }
1218
1344
  }
1219
1345
  }
1220
1346
 
@@ -1224,7 +1350,11 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1224
1350
  lock.unlock()
1225
1351
  // One left by an earlier launch belongs to the generation this launch began with.
1226
1352
  let started = transfer?.generation ?? 0
1227
- 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
1228
1358
  guard let http = downloadTask.response as? HTTPURLResponse else {
1229
1359
  record(downloadTask, .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep received a response that is not HTTP")))
1230
1360
  return
@@ -1232,7 +1362,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1232
1362
  // The system deletes `location` once this returns: move it now. It
1233
1363
  // lands whether its keep is still waiting or was answered early; only
1234
1364
  // a clear since it started (sign-out) keeps it out.
1235
- 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 }))
1236
1366
  }
1237
1367
 
1238
1368
  /// How many times the cache was cleared in this launch; a transfer started before the last clear does not land.
@@ -1256,7 +1386,7 @@ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
1256
1386
  lock.lock()
1257
1387
  let transfer = transfers.removeValue(forKey: task.taskIdentifier)
1258
1388
  let result = finished.removeValue(forKey: task.taskIdentifier)
1259
- 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) }
1260
1390
  lock.unlock()
1261
1391
  guard let transfer, let flight = transfer.flight else { return }
1262
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
+ }
@@ -9301,7 +9301,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9301
9301
  field.textColor = color(style["color"]) ?? .label
9302
9302
  field.font = textFont(style, default: .systemFont(ofSize: 16))
9303
9303
  field.textAlignment = textAlignment(style["textAlign"])
9304
- field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment)
9304
+ field.defaultTextAttributes = inputTextAttributes(style, font: field.font ?? .systemFont(ofSize: 16), alignment: field.textAlignment, singleLine: true)
9305
9305
  let desiredKeyboardType = keyboardType(props["keyboardType"])
9306
9306
  if field.keyboardType != desiredKeyboardType { field.keyboardType = desiredKeyboardType }
9307
9307
  let desiredReturnKeyType = returnKeyType(props["returnKeyType"])
@@ -10930,7 +10930,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10930
10930
  private func inputTextAttributes(
10931
10931
  _ style: [String: Any],
10932
10932
  font: UIFont,
10933
- alignment: NSTextAlignment
10933
+ alignment: NSTextAlignment,
10934
+ singleLine: Bool = false
10934
10935
  ) -> [NSAttributedString.Key: Any] {
10935
10936
  var attributes: [NSAttributedString.Key: Any] = [
10936
10937
  .foregroundColor: color(style["color"]) ?? .label,
@@ -10940,6 +10941,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10940
10941
  // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
10941
10942
  let paragraph = NSMutableParagraphStyle()
10942
10943
  paragraph.alignment = alignment
10944
+ // The default paragraph wraps even inside a UITextField. A long URL
10945
+ // then paints a second line outside the field instead of scrolling.
10946
+ if singleLine { paragraph.lineBreakMode = .byClipping }
10943
10947
  if let lineHeight = number(style["lineHeight"]) {
10944
10948
  paragraph.minimumLineHeight = lineHeight
10945
10949
  paragraph.maximumLineHeight = lineHeight