@craft-native/ios 0.0.464 → 0.0.471
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 +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/templates/CraftFramePacingLog.swift +18 -0
- package/templates/CraftHybrid.swift +5 -5
- package/templates/CraftNativeActions.swift +21 -1
- package/templates/CraftNativeFiles.swift +149 -19
- package/templates/CraftNativeMediaOwnership.swift +92 -0
- package/templates/CraftNativeScreen.swift +10 -2
- package/templates/testing/DriverTests.swift.template +27 -3
package/dist/cli.js
CHANGED
|
@@ -1397,9 +1397,11 @@ 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"));
|
|
1404
|
+
writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
|
|
1403
1405
|
writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
1404
1406
|
writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
|
|
1405
1407
|
writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
|
package/dist/index.js
CHANGED
|
@@ -1611,9 +1611,11 @@ 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"));
|
|
1618
|
+
writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
|
|
1617
1619
|
writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
1618
1620
|
writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
|
|
1619
1621
|
writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The opt-in frame trace shares one append-only file for this app process.
|
|
4
|
+
enum CraftFramePacingLog {
|
|
5
|
+
static func open(in directory: URL) throws -> FileHandle {
|
|
6
|
+
let manager = FileManager.default
|
|
7
|
+
try manager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
8
|
+
let url = directory.appendingPathComponent("craft-frame-pacing.log")
|
|
9
|
+
if !manager.fileExists(atPath: url.path),
|
|
10
|
+
!manager.createFile(atPath: url.path, contents: nil) {
|
|
11
|
+
throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError,
|
|
12
|
+
userInfo: [NSFilePathErrorKey: url.path])
|
|
13
|
+
}
|
|
14
|
+
let handle = try FileHandle(forWritingTo: url)
|
|
15
|
+
_ = try handle.seekToEnd()
|
|
16
|
+
return handle
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -2703,11 +2703,11 @@ final class CraftFramePacing: NSObject {
|
|
|
2703
2703
|
private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
|
|
2704
2704
|
private lazy var file: FileHandle? = {
|
|
2705
2705
|
guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2706
|
+
do { return try CraftFramePacingLog.open(in: caches) }
|
|
2707
|
+
catch {
|
|
2708
|
+
NSLog("[craft frames] trace file unavailable: %@", String(describing: error))
|
|
2709
|
+
return nil
|
|
2710
|
+
}
|
|
2711
2711
|
}()
|
|
2712
2712
|
|
|
2713
2713
|
private func write(_ line: String) {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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"])
|
|
@@ -10779,6 +10779,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10779
10779
|
// beneath the screen's floating chrome.
|
|
10780
10780
|
let overflow = style["overflow"] as? String
|
|
10781
10781
|
view.clipsToBounds = overflow == "hidden"
|
|
10782
|
+
// A single-line editor owns a text viewport. Its private label
|
|
10783
|
+
// can be wider than the control while editing or showing a URL;
|
|
10784
|
+
// inherited visible overflow must not open that viewport.
|
|
10785
|
+
|| view is UITextField
|
|
10782
10786
|
|| (view is UIScrollView && overflow != "visible")
|
|
10783
10787
|
|| (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
|
|
10784
10788
|
|| ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
|
|
@@ -10930,7 +10934,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10930
10934
|
private func inputTextAttributes(
|
|
10931
10935
|
_ style: [String: Any],
|
|
10932
10936
|
font: UIFont,
|
|
10933
|
-
alignment: NSTextAlignment
|
|
10937
|
+
alignment: NSTextAlignment,
|
|
10938
|
+
singleLine: Bool = false
|
|
10934
10939
|
) -> [NSAttributedString.Key: Any] {
|
|
10935
10940
|
var attributes: [NSAttributedString.Key: Any] = [
|
|
10936
10941
|
.foregroundColor: color(style["color"]) ?? .label,
|
|
@@ -10940,6 +10945,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
10940
10945
|
// UIKit otherwise resets the field's alignment to natural when these attributes are applied.
|
|
10941
10946
|
let paragraph = NSMutableParagraphStyle()
|
|
10942
10947
|
paragraph.alignment = alignment
|
|
10948
|
+
// The default paragraph wraps even inside a UITextField. A long URL
|
|
10949
|
+
// then paints a second line outside the field instead of scrolling.
|
|
10950
|
+
if singleLine { paragraph.lineBreakMode = .byClipping }
|
|
10943
10951
|
if let lineHeight = number(style["lineHeight"]) {
|
|
10944
10952
|
paragraph.minimumLineHeight = lineHeight
|
|
10945
10953
|
paragraph.maximumLineHeight = lineHeight
|
|
@@ -182,6 +182,30 @@ final class DriverTests: XCTestCase {
|
|
|
182
182
|
return nil
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/// Safari's web view extends behind its browser bars. Those regions accept
|
|
186
|
+
/// touches for the bars, not for page controls reported inside the web view.
|
|
187
|
+
func browserContentFrame(_ frame: CGRect, topBar: CGRect?, bottomBar: CGRect?) -> CGRect {
|
|
188
|
+
var top = frame.minY
|
|
189
|
+
var bottom = frame.maxY
|
|
190
|
+
if let bar = topBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
|
|
191
|
+
top = max(top, bar.maxY)
|
|
192
|
+
}
|
|
193
|
+
if let bar = bottomBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
|
|
194
|
+
bottom = min(bottom, bar.minY)
|
|
195
|
+
}
|
|
196
|
+
return CGRect(x: frame.minX, y: top, width: frame.width, height: max(0, bottom - top))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func scrollViewport() -> CGRect {
|
|
200
|
+
let webView = app.webViews.firstMatch
|
|
201
|
+
let topBar = app.otherElements["TopBrowserBar"]
|
|
202
|
+
let bottomBar = app.toolbars["BottomBrowserToolbar"]
|
|
203
|
+
guard webView.exists, topBar.exists || bottomBar.exists else { return app.frame }
|
|
204
|
+
return browserContentFrame(webView.frame.intersection(app.frame),
|
|
205
|
+
topBar: topBar.exists ? topBar.frame : nil,
|
|
206
|
+
bottomBar: bottomBar.exists ? bottomBar.frame : nil)
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
/// A short target fits entirely; an oversized target fills the viewport.
|
|
186
210
|
func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
|
|
187
211
|
let visible = target.intersection(viewport)
|
|
@@ -192,7 +216,7 @@ final class DriverTests: XCTestCase {
|
|
|
192
216
|
|
|
193
217
|
func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
|
|
194
218
|
guard let target, target.exists, target.isHittable,
|
|
195
|
-
visibleScrollFrame(target.frame, in:
|
|
219
|
+
visibleScrollFrame(target.frame, in: scrollViewport()) else { return false }
|
|
196
220
|
// Hittable alone accepts controls clipped by the scroll view's edge.
|
|
197
221
|
// Only an ancestor scroll view clips this target, not its siblings.
|
|
198
222
|
// One that does not overlap the target is not its ancestor, whatever
|
|
@@ -220,7 +244,7 @@ final class DriverTests: XCTestCase {
|
|
|
220
244
|
/// below the screen while drawing it over the lower half - and it stops
|
|
221
245
|
/// short of a keyboard whose frame is known.
|
|
222
246
|
func scrollPage(towardTop: Bool) {
|
|
223
|
-
let frame =
|
|
247
|
+
let frame = scrollViewport()
|
|
224
248
|
var lower = frame.minY + frame.height * 0.55
|
|
225
249
|
let keyboard = app.keyboards.firstMatch
|
|
226
250
|
if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
|
|
@@ -719,7 +743,7 @@ final class DriverTests: XCTestCase {
|
|
|
719
743
|
var found = element(containing: arg)
|
|
720
744
|
var drags = 0
|
|
721
745
|
while !scrollTargetVisible(found) && drags < 12 {
|
|
722
|
-
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY <
|
|
746
|
+
let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < scrollViewport().midY } ?? false
|
|
723
747
|
scrollPage(towardTop: towardTop)
|
|
724
748
|
drags += 1
|
|
725
749
|
found = element(containing: arg)
|