@otakit/capacitor-updater 2.3.2 → 3.0.0
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/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
- package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- package/package.json +7 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct DownloadHTTPError: Error, LocalizedError {
|
|
4
|
+
let status: Int
|
|
5
|
+
let retryAfter: String?
|
|
6
|
+
var errorDescription: String? { "Download failed with HTTP \(status)" }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Bounded retries for GET transport failures. Integrity and disk failures stay terminal.
|
|
10
|
+
struct DownloadRetry {
|
|
11
|
+
static func isExpiredURLFailure(_ error: Error) -> Bool {
|
|
12
|
+
guard let http = error as? DownloadHTTPError else { return false }
|
|
13
|
+
return http.status == 403 || http.status == 410
|
|
14
|
+
}
|
|
15
|
+
var now: () -> Date = { Date() }
|
|
16
|
+
var random: () -> Double = { Double.random(in: 0..<1) }
|
|
17
|
+
|
|
18
|
+
func delay(for error: Error, attempt: Int) -> TimeInterval? {
|
|
19
|
+
guard attempt >= 1, attempt < 3, !Task.isCancelled else { return nil }
|
|
20
|
+
var serverDelay: TimeInterval?
|
|
21
|
+
if let http = error as? DownloadHTTPError {
|
|
22
|
+
guard [408, 425, 429, 500, 502, 503, 504].contains(http.status) else { return nil }
|
|
23
|
+
serverDelay = Self.retryAfter(http.retryAfter, now: now())
|
|
24
|
+
// Do not sleep indefinitely or retry earlier than a long server-requested delay.
|
|
25
|
+
if let serverDelay, serverDelay > 30 { return nil }
|
|
26
|
+
} else {
|
|
27
|
+
let failure = error as NSError
|
|
28
|
+
guard failure.domain == NSURLErrorDomain,
|
|
29
|
+
[NSURLErrorTimedOut, NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost,
|
|
30
|
+
NSURLErrorNetworkConnectionLost, NSURLErrorDNSLookupFailed,
|
|
31
|
+
NSURLErrorNotConnectedToInternet].contains(failure.code) else { return nil }
|
|
32
|
+
}
|
|
33
|
+
let backoff = pow(2, Double(attempt - 1)) * (0.5 + random())
|
|
34
|
+
return max(backoff, serverDelay ?? 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static func retryAfter(_ value: String?, now: Date) -> TimeInterval? {
|
|
38
|
+
guard let value else { return nil }
|
|
39
|
+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
40
|
+
if !trimmed.isEmpty, trimmed.allSatisfy({ $0 >= "0" && $0 <= "9" }) {
|
|
41
|
+
return Double(trimmed) ?? .infinity
|
|
42
|
+
}
|
|
43
|
+
let formatter = DateFormatter()
|
|
44
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
45
|
+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
46
|
+
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
47
|
+
formatter.isLenient = false
|
|
48
|
+
guard let date = formatter.date(from: trimmed) else { return nil }
|
|
49
|
+
return max(0, date.timeIntervalSince(now))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -2,9 +2,19 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
final class Downloader {
|
|
4
4
|
private let allowInsecureUrls: Bool
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
private let retry: DownloadRetry
|
|
6
|
+
private let sleep: (TimeInterval) async throws -> Void
|
|
7
|
+
|
|
8
|
+
init(
|
|
9
|
+
allowInsecureUrls: Bool = false,
|
|
10
|
+
retry: DownloadRetry = DownloadRetry(),
|
|
11
|
+
sleep: @escaping (TimeInterval) async throws -> Void = {
|
|
12
|
+
try await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000))
|
|
13
|
+
}
|
|
14
|
+
) {
|
|
7
15
|
self.allowInsecureUrls = allowInsecureUrls
|
|
16
|
+
self.retry = retry
|
|
17
|
+
self.sleep = sleep
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
func download(
|
|
@@ -12,51 +22,85 @@ final class Downloader {
|
|
|
12
22
|
progress: @escaping (Double, Int64, Int64) -> Void = { _, _, _ in }
|
|
13
23
|
) async throws -> URL {
|
|
14
24
|
try ManifestClient.requireHTTPS(url: url, allowInsecure: allowInsecureUrls)
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
var attempt = 1
|
|
26
|
+
while true {
|
|
27
|
+
try Task.checkCancellation()
|
|
28
|
+
let delegate = DownloadDelegate(allowInsecureUrls: allowInsecureUrls, progressHandler: progress)
|
|
29
|
+
do {
|
|
30
|
+
let downloaded = try await delegate.start(from: url)
|
|
31
|
+
do { try Task.checkCancellation() }
|
|
32
|
+
catch { try? FileManager.default.removeItem(at: downloaded); throw error }
|
|
33
|
+
return downloaded
|
|
34
|
+
} catch {
|
|
35
|
+
guard let delay = retry.delay(for: error, attempt: attempt) else { throw error }
|
|
36
|
+
try await sleep(delay)
|
|
37
|
+
attempt += 1
|
|
38
|
+
}
|
|
39
|
+
}
|
|
17
40
|
}
|
|
18
41
|
}
|
|
19
42
|
|
|
20
43
|
private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
21
44
|
private var continuation: CheckedContinuation<URL, Error>?
|
|
22
45
|
private var session: URLSession?
|
|
46
|
+
private let allowInsecureUrls: Bool
|
|
23
47
|
private let progressHandler: (Double, Int64, Int64) -> Void
|
|
24
48
|
private let stateLock = NSLock()
|
|
25
49
|
private var isResolved = false
|
|
50
|
+
private var cancelled = false
|
|
26
51
|
|
|
27
|
-
init(progressHandler: @escaping (Double, Int64, Int64) -> Void) {
|
|
52
|
+
init(allowInsecureUrls: Bool, progressHandler: @escaping (Double, Int64, Int64) -> Void) {
|
|
53
|
+
self.allowInsecureUrls = allowInsecureUrls
|
|
28
54
|
self.progressHandler = progressHandler
|
|
29
55
|
super.init()
|
|
30
56
|
}
|
|
31
57
|
|
|
32
58
|
func start(from url: URL) async throws -> URL {
|
|
33
|
-
try await
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
try await withTaskCancellationHandler {
|
|
60
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
61
|
+
stateLock.lock()
|
|
62
|
+
if cancelled {
|
|
63
|
+
stateLock.unlock()
|
|
64
|
+
continuation.resume(throwing: CancellationError())
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
self.continuation = continuation
|
|
68
|
+
let configuration = URLSessionConfiguration.default
|
|
69
|
+
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
70
|
+
self.session = session
|
|
71
|
+
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60)
|
|
72
|
+
request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
|
73
|
+
let task = session.downloadTask(with: request)
|
|
74
|
+
stateLock.unlock()
|
|
75
|
+
task.resume()
|
|
76
|
+
}
|
|
77
|
+
} onCancel: {
|
|
78
|
+
self.cancel()
|
|
41
79
|
}
|
|
42
80
|
}
|
|
43
81
|
|
|
44
|
-
private func
|
|
82
|
+
private func cancel() {
|
|
83
|
+
stateLock.lock()
|
|
84
|
+
cancelled = true
|
|
85
|
+
stateLock.unlock()
|
|
86
|
+
resolve(.failure(CancellationError()))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@discardableResult
|
|
90
|
+
private func resolve(_ result: Result<URL, Error>) -> Bool {
|
|
45
91
|
stateLock.lock()
|
|
46
92
|
guard !isResolved, let continuation else {
|
|
47
93
|
stateLock.unlock()
|
|
48
|
-
return
|
|
94
|
+
return false
|
|
49
95
|
}
|
|
50
96
|
isResolved = true
|
|
51
97
|
self.continuation = nil
|
|
98
|
+
let session = self.session
|
|
99
|
+
self.session = nil
|
|
52
100
|
stateLock.unlock()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
continuation.resume(returning: url)
|
|
57
|
-
case let .failure(error):
|
|
58
|
-
continuation.resume(throwing: error)
|
|
59
|
-
}
|
|
101
|
+
session?.invalidateAndCancel()
|
|
102
|
+
continuation.resume(with: result)
|
|
103
|
+
return true
|
|
60
104
|
}
|
|
61
105
|
|
|
62
106
|
func urlSession(
|
|
@@ -64,16 +108,12 @@ private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
64
108
|
downloadTask: URLSessionDownloadTask,
|
|
65
109
|
didFinishDownloadingTo location: URL
|
|
66
110
|
) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(httpResponse.statusCode)"]
|
|
74
|
-
)))
|
|
75
|
-
session.finishTasksAndInvalidate()
|
|
76
|
-
self.session = nil
|
|
111
|
+
guard let response = downloadTask.response as? HTTPURLResponse else {
|
|
112
|
+
resolve(.failure(URLError(.badServerResponse)))
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if response.statusCode != 200 {
|
|
116
|
+
resolve(.failure(DownloadHTTPError(status: response.statusCode, retryAfter: response.value(forHTTPHeaderField: "Retry-After"))))
|
|
77
117
|
return
|
|
78
118
|
}
|
|
79
119
|
|
|
@@ -82,13 +122,26 @@ private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
82
122
|
|
|
83
123
|
do {
|
|
84
124
|
try FileManager.default.moveItem(at: location, to: temporaryZip)
|
|
85
|
-
|
|
125
|
+
// Cancellation can win while the delegate moves the completed download.
|
|
126
|
+
if !resolve(.success(temporaryZip)) { try? FileManager.default.removeItem(at: temporaryZip) }
|
|
86
127
|
} catch {
|
|
87
128
|
resolve(.failure(error))
|
|
88
129
|
}
|
|
130
|
+
}
|
|
89
131
|
|
|
90
|
-
|
|
91
|
-
|
|
132
|
+
func urlSession(
|
|
133
|
+
_ session: URLSession, task: URLSessionTask,
|
|
134
|
+
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
|
135
|
+
completionHandler: @escaping (URLRequest?) -> Void
|
|
136
|
+
) {
|
|
137
|
+
do {
|
|
138
|
+
guard let url = request.url else { throw URLError(.badURL) }
|
|
139
|
+
try ManifestClient.requireHTTPS(url: url, allowInsecure: allowInsecureUrls)
|
|
140
|
+
completionHandler(request)
|
|
141
|
+
} catch {
|
|
142
|
+
completionHandler(nil)
|
|
143
|
+
resolve(.failure(error))
|
|
144
|
+
}
|
|
92
145
|
}
|
|
93
146
|
|
|
94
147
|
func urlSession(
|
|
@@ -111,10 +164,15 @@ private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
|
111
164
|
didCompleteWithError error: Error?
|
|
112
165
|
) {
|
|
113
166
|
if let error {
|
|
114
|
-
|
|
167
|
+
let failure = error as NSError
|
|
168
|
+
if failure.domain == NSURLErrorDomain, failure.code == NSURLErrorCancelled {
|
|
169
|
+
resolve(.failure(CancellationError()))
|
|
170
|
+
} else {
|
|
171
|
+
resolve(.failure(error))
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// A successful download normally resolves in didFinishDownloadingTo.
|
|
175
|
+
resolve(.failure(URLError(.badServerResponse)))
|
|
115
176
|
}
|
|
116
|
-
|
|
117
|
-
session.finishTasksAndInvalidate()
|
|
118
|
-
self.session = nil
|
|
119
177
|
}
|
|
120
178
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Atomic, bounded persistence; retries preserve the exact original event body.
|
|
4
|
+
final class EventOutbox {
|
|
5
|
+
static let limit = 256
|
|
6
|
+
static let ttl: TimeInterval = 7 * 24 * 60 * 60
|
|
7
|
+
struct Entry: Codable {
|
|
8
|
+
let id: String
|
|
9
|
+
let url: URL
|
|
10
|
+
let appId: String
|
|
11
|
+
let body: Data
|
|
12
|
+
let createdAt: TimeInterval
|
|
13
|
+
var attempts: Int
|
|
14
|
+
var nextAt: TimeInterval
|
|
15
|
+
}
|
|
16
|
+
private let file: URL
|
|
17
|
+
private let lock = NSLock()
|
|
18
|
+
private var entries: [Entry] = []
|
|
19
|
+
|
|
20
|
+
init(directory: URL) throws {
|
|
21
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
22
|
+
var directory = directory
|
|
23
|
+
var values = URLResourceValues()
|
|
24
|
+
values.isExcludedFromBackup = true
|
|
25
|
+
try directory.setResourceValues(values)
|
|
26
|
+
file = directory.appendingPathComponent("events.json")
|
|
27
|
+
if FileManager.default.fileExists(atPath: file.path) {
|
|
28
|
+
let size = try file.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
|
|
29
|
+
guard size <= 4 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) }
|
|
30
|
+
let data = try Data(contentsOf: file)
|
|
31
|
+
do {
|
|
32
|
+
entries = try JSONDecoder().decode([Entry].self, from: data)
|
|
33
|
+
guard entries.count <= Self.limit else { throw CocoaError(.fileReadCorruptFile) }
|
|
34
|
+
} catch {
|
|
35
|
+
print("[OtaKit] Discarding unreadable event outbox")
|
|
36
|
+
try save([])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func enqueue(url: URL, appId: String, body: Data, now: TimeInterval) throws {
|
|
42
|
+
lock.lock(); defer { lock.unlock() }
|
|
43
|
+
guard body.count <= 8192,
|
|
44
|
+
let payload = try JSONSerialization.jsonObject(with: body) as? [String: Any],
|
|
45
|
+
let id = payload["eventId"] as? String else { throw CocoaError(.fileWriteInvalidFileName) }
|
|
46
|
+
var next = live(now)
|
|
47
|
+
guard !next.contains(where: { $0.id == id }) else { return }
|
|
48
|
+
while next.count >= Self.limit { next.removeFirst() }
|
|
49
|
+
next.append(Entry(id: id, url: url, appId: appId, body: body, createdAt: now, attempts: 0, nextAt: now))
|
|
50
|
+
try save(next)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func ready(now: TimeInterval) throws -> Entry? {
|
|
54
|
+
lock.lock(); defer { lock.unlock() }
|
|
55
|
+
let next = live(now)
|
|
56
|
+
if next.count != entries.count { try save(next) }
|
|
57
|
+
return entries.first { $0.nextAt <= now }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func wait(now: TimeInterval) -> TimeInterval? {
|
|
61
|
+
lock.lock(); defer { lock.unlock() }
|
|
62
|
+
return entries.map { max(0, min($0.nextAt, $0.createdAt + Self.ttl) - now) }.min()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func complete(id: String, status: Int, retryAfter: String?, now: TimeInterval, random: Double) throws {
|
|
66
|
+
lock.lock(); defer { lock.unlock() }
|
|
67
|
+
var next = live(now)
|
|
68
|
+
if let index = next.firstIndex(where: { $0.id == id }) {
|
|
69
|
+
if (200..<300).contains(status) || ((300..<500).contains(status) && ![408, 425, 429].contains(status)) {
|
|
70
|
+
next.remove(at: index)
|
|
71
|
+
} else {
|
|
72
|
+
next[index].attempts = min(next[index].attempts + 1, 20)
|
|
73
|
+
let backoff = min(3600, 5 * pow(2, Double(min(next[index].attempts - 1, 10)))) * (0.5 + random)
|
|
74
|
+
let serverDelay = DownloadRetry.retryAfter(retryAfter, now: Date(timeIntervalSince1970: now)) ?? 0
|
|
75
|
+
// Expiration is the upper bound; never retry before a longer server delay.
|
|
76
|
+
next[index].nextAt = min(now + max(backoff, serverDelay), next[index].createdAt + Self.ttl)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
try save(next)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func live(_ now: TimeInterval) -> [Entry] {
|
|
83
|
+
entries.filter { now < $0.createdAt + Self.ttl }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func save(_ next: [Entry]) throws {
|
|
87
|
+
try JSONEncoder().encode(next).write(to: file, options: .atomic)
|
|
88
|
+
entries = next
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Owns one serial delivery loop independently of any Capacitor bridge lifetime.
|
|
93
|
+
final class EventDelivery: NSObject, URLSessionTaskDelegate {
|
|
94
|
+
private let worker = DispatchQueue(label: "OtaKit.events")
|
|
95
|
+
private let stateLock = NSLock()
|
|
96
|
+
private var outbox: EventOutbox?
|
|
97
|
+
private var scheduled: DispatchWorkItem?
|
|
98
|
+
private var sending = false
|
|
99
|
+
private let configuration: URLSessionConfiguration
|
|
100
|
+
private lazy var session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
101
|
+
|
|
102
|
+
init(outbox: EventOutbox? = nil, configuration: URLSessionConfiguration = .ephemeral) {
|
|
103
|
+
self.outbox = outbox
|
|
104
|
+
self.configuration = configuration
|
|
105
|
+
super.init()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func resume() {
|
|
109
|
+
stateLock.lock()
|
|
110
|
+
if outbox == nil {
|
|
111
|
+
do {
|
|
112
|
+
let support = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
|
|
113
|
+
appropriateFor: nil, create: true)
|
|
114
|
+
outbox = try EventOutbox(directory: support.appendingPathComponent("OtaKitEvents", isDirectory: true))
|
|
115
|
+
} catch { print("[OtaKit] Cannot open device event outbox") }
|
|
116
|
+
}
|
|
117
|
+
stateLock.unlock()
|
|
118
|
+
worker.async { self.schedule() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func enqueue(url: URL, appId: String, body: Data) {
|
|
122
|
+
stateLock.lock()
|
|
123
|
+
let outbox = self.outbox
|
|
124
|
+
stateLock.unlock()
|
|
125
|
+
do {
|
|
126
|
+
guard let outbox else { throw CocoaError(.fileWriteUnknown) }
|
|
127
|
+
try outbox.enqueue(url: url, appId: appId, body: body, now: Date().timeIntervalSince1970)
|
|
128
|
+
worker.async { self.schedule() }
|
|
129
|
+
} catch { print("[OtaKit] Cannot persist device event") }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func schedule(minimumDelay: TimeInterval = 0) {
|
|
133
|
+
guard !sending else { return }
|
|
134
|
+
scheduled?.cancel()
|
|
135
|
+
stateLock.lock(); let outbox = self.outbox; stateLock.unlock()
|
|
136
|
+
guard let delay = outbox?.wait(now: Date().timeIntervalSince1970) else { scheduled = nil; return }
|
|
137
|
+
let item = DispatchWorkItem { self.drain() }
|
|
138
|
+
scheduled = item
|
|
139
|
+
worker.asyncAfter(deadline: .now() + max(minimumDelay, delay), execute: item)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func drain() {
|
|
143
|
+
guard !sending else { return }
|
|
144
|
+
scheduled?.cancel(); scheduled = nil
|
|
145
|
+
stateLock.lock(); let outbox = self.outbox; stateLock.unlock()
|
|
146
|
+
guard let outbox else { return }
|
|
147
|
+
do {
|
|
148
|
+
guard let entry = try outbox.ready(now: Date().timeIntervalSince1970) else { schedule(); return }
|
|
149
|
+
sending = true
|
|
150
|
+
var request = URLRequest(url: entry.url)
|
|
151
|
+
request.httpMethod = "POST"
|
|
152
|
+
request.setValue(entry.appId, forHTTPHeaderField: "X-App-Id")
|
|
153
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
154
|
+
request.httpBody = entry.body
|
|
155
|
+
request.timeoutInterval = 10
|
|
156
|
+
session.dataTask(with: request) { _, response, error in
|
|
157
|
+
self.worker.async {
|
|
158
|
+
let http = response as? HTTPURLResponse
|
|
159
|
+
var storageFailed = false
|
|
160
|
+
do {
|
|
161
|
+
try outbox.complete(id: entry.id, status: error == nil ? (http?.statusCode ?? 0) : 0,
|
|
162
|
+
retryAfter: http?.value(forHTTPHeaderField: "Retry-After"),
|
|
163
|
+
now: Date().timeIntervalSince1970, random: Double.random(in: 0..<1))
|
|
164
|
+
} catch {
|
|
165
|
+
storageFailed = true
|
|
166
|
+
print("[OtaKit] Cannot persist device event delivery state")
|
|
167
|
+
}
|
|
168
|
+
self.sending = false
|
|
169
|
+
self.schedule(minimumDelay: storageFailed ? 60 : 0)
|
|
170
|
+
}
|
|
171
|
+
}.resume()
|
|
172
|
+
} catch {
|
|
173
|
+
print("[OtaKit] Cannot read device event outbox")
|
|
174
|
+
schedule(minimumDelay: 60)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func urlSession(_ session: URLSession, task: URLSessionTask,
|
|
179
|
+
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
|
180
|
+
completionHandler: @escaping (URLRequest?) -> Void) {
|
|
181
|
+
completionHandler(nil)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Owned by the main thread. Only foreground time consumes the readiness budget.
|
|
4
|
+
final class ForegroundDeadline {
|
|
5
|
+
typealias Scheduler = (TimeInterval, @escaping () -> Void) -> () -> Void
|
|
6
|
+
private let now: () -> TimeInterval
|
|
7
|
+
private let schedule: Scheduler
|
|
8
|
+
private var foreground = false
|
|
9
|
+
private var remaining: TimeInterval = 0
|
|
10
|
+
private var startedAt: TimeInterval = 0
|
|
11
|
+
private var generation = 0
|
|
12
|
+
private var action: (() -> Void)?
|
|
13
|
+
private var cancelScheduled: (() -> Void)?
|
|
14
|
+
private var observations: [(NotificationCenter, NSObjectProtocol)] = []
|
|
15
|
+
|
|
16
|
+
init(
|
|
17
|
+
now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
|
|
18
|
+
schedule: @escaping Scheduler = { delay, callback in
|
|
19
|
+
let item = DispatchWorkItem(block: callback)
|
|
20
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
|
|
21
|
+
return { item.cancel() }
|
|
22
|
+
}
|
|
23
|
+
) {
|
|
24
|
+
self.now = now
|
|
25
|
+
self.schedule = schedule
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func observe(
|
|
29
|
+
center: NotificationCenter, active: Notification.Name, inactive: Notification.Name,
|
|
30
|
+
initiallyActive: Bool
|
|
31
|
+
) {
|
|
32
|
+
setForeground(initiallyActive)
|
|
33
|
+
observations.append((center, center.addObserver(forName: active, object: nil, queue: .main) { [weak self] _ in
|
|
34
|
+
self?.setForeground(true)
|
|
35
|
+
}))
|
|
36
|
+
observations.append((center, center.addObserver(forName: inactive, object: nil, queue: .main) { [weak self] _ in
|
|
37
|
+
self?.setForeground(false)
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func start(timeout: TimeInterval, action: @escaping () -> Void) {
|
|
42
|
+
cancel()
|
|
43
|
+
remaining = max(0, timeout)
|
|
44
|
+
self.action = action
|
|
45
|
+
arm()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func setForeground(_ value: Bool) {
|
|
49
|
+
guard value != foreground else { return }
|
|
50
|
+
if foreground, action != nil {
|
|
51
|
+
remaining = max(0, remaining - max(0, now() - startedAt))
|
|
52
|
+
}
|
|
53
|
+
disarm()
|
|
54
|
+
foreground = value
|
|
55
|
+
arm()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func cancel() {
|
|
59
|
+
disarm()
|
|
60
|
+
action = nil
|
|
61
|
+
remaining = 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private func disarm() {
|
|
65
|
+
generation += 1
|
|
66
|
+
cancelScheduled?()
|
|
67
|
+
cancelScheduled = nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func arm() {
|
|
71
|
+
guard foreground, action != nil else { return }
|
|
72
|
+
startedAt = now()
|
|
73
|
+
generation += 1
|
|
74
|
+
let expectedGeneration = generation
|
|
75
|
+
cancelScheduled = schedule(remaining) { [weak self] in
|
|
76
|
+
self?.fire(expectedGeneration: expectedGeneration)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private func fire(expectedGeneration: Int) {
|
|
81
|
+
guard generation == expectedGeneration, foreground, let action else { return }
|
|
82
|
+
remaining = max(0, remaining - max(0, now() - startedAt))
|
|
83
|
+
cancelScheduled = nil
|
|
84
|
+
if remaining > 0 { arm(); return }
|
|
85
|
+
self.action = nil
|
|
86
|
+
generation += 1
|
|
87
|
+
action()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
deinit {
|
|
91
|
+
cancelScheduled?()
|
|
92
|
+
for (center, observation) in observations { center.removeObserver(observation) }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -30,4 +30,20 @@ enum HashUtils {
|
|
|
30
30
|
let actual = try sha256(fileURL: fileURL)
|
|
31
31
|
return actual.lowercased() == expectedSha256.lowercased()
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
static func verifyDownload(fileURL: URL, expectedSha256: String, expectedBytes: Int?, kind: String) throws {
|
|
35
|
+
let actual = try sha256(fileURL: fileURL)
|
|
36
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
|
|
37
|
+
guard let size = attributes[.size] as? NSNumber else { throw HashUtilsError.couldNotOpenFile }
|
|
38
|
+
let received = size.int64Value
|
|
39
|
+
let hashMatches = actual.caseInsensitiveCompare(expectedSha256) == .orderedSame
|
|
40
|
+
let sizeMatches = expectedBytes.map { Int64($0) == received } ?? true
|
|
41
|
+
guard hashMatches && sizeMatches else {
|
|
42
|
+
let safeExpected = expectedSha256.range(of: "^[a-fA-F0-9]{64}$", options: .regularExpression) != nil
|
|
43
|
+
? expectedSha256.lowercased() : "invalid"
|
|
44
|
+
let reason = hashMatches ? "size mismatch" : "hash mismatch"
|
|
45
|
+
throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey:
|
|
46
|
+
"Downloaded \(kind) \(reason); expectedSha256=\(safeExpected); actualSha256=\(actual); expectedBytes=\(expectedBytes ?? -1); receivedBytes=\(received)"])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
33
49
|
}
|
|
@@ -45,6 +45,7 @@ enum ManifestClientError: Error {
|
|
|
45
45
|
case invalidURL
|
|
46
46
|
case invalidResponse
|
|
47
47
|
case requestFailed(String)
|
|
48
|
+
case httpStatus(Int)
|
|
48
49
|
case insecureURL(String)
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -107,10 +108,7 @@ enum ManifestClient {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
guard httpResponse.statusCode == 200 else {
|
|
110
|
-
|
|
111
|
-
throw ManifestClientError.requestFailed(
|
|
112
|
-
"HTTP \(httpResponse.statusCode): \(body)"
|
|
113
|
-
)
|
|
111
|
+
throw ManifestClientError.httpStatus(httpResponse.statusCode)
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
guard
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Empty means intentionally unconfigured; malformed explicit settings must never return empty.
|
|
4
|
+
enum ManifestKeyConfig {
|
|
5
|
+
static func parse(_ config: [String: Any]) -> [(kid: String, key: Data)] {
|
|
6
|
+
guard let rawValue = config["manifestKeys"] else { return [] }
|
|
7
|
+
guard let entries = rawValue as? [[String: String]] else { return invalid() }
|
|
8
|
+
let keys: [(kid: String, key: Data)] = entries.compactMap { entry in
|
|
9
|
+
guard let kid = entry["kid"], let encoded = entry["key"],
|
|
10
|
+
let data = Data(base64Encoded: encoded) else { return nil }
|
|
11
|
+
return (kid: kid, key: data)
|
|
12
|
+
}
|
|
13
|
+
return keys.isEmpty && !entries.isEmpty ? invalid() : keys
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private static func invalid() -> [(kid: String, key: Data)] {
|
|
17
|
+
print("[OtaKit] ERROR: Invalid manifestKeys configuration. Manifest verification will reject all updates.")
|
|
18
|
+
return [(kid: "_invalid_", key: Data())]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -51,7 +51,13 @@ enum ManifestVerifier {
|
|
|
51
51
|
iat: signature.iat,
|
|
52
52
|
exp: signature.exp
|
|
53
53
|
)
|
|
54
|
-
|
|
54
|
+
do {
|
|
55
|
+
try verifyPayload(payload, signature: signature, trustedKeys: trustedKeys)
|
|
56
|
+
} catch let error as ManifestVerifierError {
|
|
57
|
+
throw error
|
|
58
|
+
} catch {
|
|
59
|
+
throw ManifestVerifierError.invalidSignature
|
|
60
|
+
}
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
private static func verifyPayload(
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Checked on the main thread together with state publication and activation.
|
|
4
|
+
final class UpdateOwner {
|
|
5
|
+
private let isAvailable: () -> Bool
|
|
6
|
+
init(isAvailable: @escaping () -> Bool) { self.isAvailable = isAvailable }
|
|
7
|
+
|
|
8
|
+
func run<T>(_ action: () throws -> T) throws -> T {
|
|
9
|
+
guard isAvailable() else { throw CancellationError() }
|
|
10
|
+
return try action()
|
|
11
|
+
}
|
|
12
|
+
}
|