craft-native 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.
- package/dist/android/src/index.d.ts +8 -0
- package/dist/android/src/index.js +227 -30
- package/dist/android/src/testing.d.ts +1 -1
- package/dist/android/templates/CraftNativeBackground.kt.template +65 -25
- package/dist/android/templates/CraftNativeCapabilities.kt.template +3 -1
- package/dist/android/templates/CraftNativeMessagingService.kt.template +8 -28
- package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +213 -15
- package/dist/android/templates/CraftNativeServices.kt.template +23 -5
- package/dist/android/templates/CraftNativeShell.kt.template +201 -50
- package/dist/android/templates/MainActivityNative.kt.template +227 -12
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +18 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +13 -14
- package/dist/ios/src/index.js +293 -81
- package/dist/ios/templates/CraftApp.swift +118 -16
- package/dist/ios/templates/CraftHybrid.swift +75 -5
- package/dist/ios/templates/CraftNativeActions.swift +105 -14
- package/dist/ios/templates/CraftNativeFiles.swift +230 -18
- package/dist/ios/templates/CraftNativeMediaOwnership.swift +92 -0
- package/dist/ios/templates/CraftNativeScreen.swift +136 -6
- package/dist/ios/templates/CraftNotificationService.swift +199 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +29 -1
- package/dist/ios/templates/testing/DriverTests.swift.template +44 -5
- package/dist/mobile-testing.js +12 -3
- package/dist/mobile.d.ts +2 -0
- package/dist/mobile.js +120 -0
- package/dist/native-notifications.d.ts +17 -0
- package/dist/native-routing.d.ts +26 -0
- package/dist/testing/media-cache/media-cache.kt +74 -5
- package/dist/testing/media-cache/media-cache.swift.fixture +60 -4
- package/dist/typescript/src/native-notifications.d.ts +17 -0
- package/dist/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|