@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.
Files changed (57) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
  22. package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
  23. package/dist/esm/definitions.d.ts +3 -3
  24. package/dist/esm/definitions.d.ts.map +1 -1
  25. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  26. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  27. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  28. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  29. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  30. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  31. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  32. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  33. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  34. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  35. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  36. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  37. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  38. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  40. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  41. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  44. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  45. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  46. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  47. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  48. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  50. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  51. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  52. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  53. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  54. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  56. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  57. 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
- init(allowInsecureUrls: Bool = false) {
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
- let delegate = DownloadDelegate(progressHandler: progress)
16
- return try await delegate.start(from: url)
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 withCheckedThrowingContinuation { continuation in
34
- self.continuation = continuation
35
-
36
- let configuration = URLSessionConfiguration.default
37
- let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
38
- self.session = session
39
- let task = session.downloadTask(with: url)
40
- task.resume()
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 resolve(_ result: Result<URL, Error>) {
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
- switch result {
55
- case let .success(url):
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
- // Check HTTP status before treating the file as a valid download
68
- if let httpResponse = downloadTask.response as? HTTPURLResponse,
69
- !(200..<300).contains(httpResponse.statusCode) {
70
- resolve(.failure(NSError(
71
- domain: "Downloader",
72
- code: httpResponse.statusCode,
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
- resolve(.success(temporaryZip))
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
- session.finishTasksAndInvalidate()
91
- self.session = nil
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
- resolve(.failure(error))
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
- let body = String(data: data, encoding: .utf8) ?? "unknown"
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
- try verifyPayload(payload, signature: signature, trustedKeys: trustedKeys)
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,4 @@
1
+ // Generated from package.json by scripts/generate-native-sdk-version.mjs.
2
+ enum SDKVersion {
3
+ static let value = "3.0.0"
4
+ }
@@ -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
+ }